You've already forked AstralRinth
* starting on new checklist implementation Change default shouldShow behavior for stages. add new messages and stages. Change some existing stage logic. Add placeholder var for the rules. Co-Authored-By: @coolbot100s * misc fixes + corrections * Add clickable link previews to links stage * Correct mislabeled title message and add new title messages * Change message formatting, use rules variable, correct wip desc and title 1.8 messages, add tags buttons * More applications of rules placeholder * Add new status alerts stage * change order of statusAlerts * Update title related messages, add navigation based vars * Overhaul Links stage and add new messages. * Set message weights, add some disables * message.mds now obey lint >:( * fixed links text message formatting and changed an icon * Combine title and slug stages * Add more info to some stages and properly case stage ids * tweak summary text formatting * Improved tags stage info and more navigation placeholders * redo reupload stage, more navigation placeholders, licensing stage improvements, versions stage improvements, status alerts stage improvements * Allow modpack permissions stage to appear again by adding a dummy button. * Update modpack permissions guidance * fix: blog path issues * fix: lint issues * fix license stage text formatting * Improve license stage * feat: move links into one md file to be cleaner * Update packages/moderation/data/stages/links.ts Signed-off-by: IMB11 <hendersoncal117@gmail.com> --------- Signed-off-by: IMB11 <hendersoncal117@gmail.com> Co-authored-by: IMB11 <hendersoncal117@gmail.com> Co-authored-by: IMB11 <calum@modrinth.com>
1343 lines
41 KiB
Vue
1343 lines
41 KiB
Vue
<template>
|
|
<KeybindsModal ref="keybindsModal" />
|
|
<div
|
|
tabindex="0"
|
|
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
|
|
:class="collapsed ? '!w-fit' : ''"
|
|
>
|
|
<div class="flex grow-0 items-center gap-2">
|
|
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
|
|
<ScaleIcon class="text-orange" /> Moderation
|
|
</h1>
|
|
<ButtonStyled circular>
|
|
<button v-tooltip="`Keyboard shortcuts`" @click="keybindsModal?.show($event)">
|
|
<KeyboardIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular>
|
|
<a v-tooltip="`Stage guidance`" target="_blank" :href="currentStageObj.guidance_url">
|
|
<FileTextIcon />
|
|
</a>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
|
<button v-tooltip="`Reset progress`" @click="resetProgress">
|
|
<BrushCleaningIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
|
<button v-tooltip="`Exit moderation`" @click="emit('exit')">
|
|
<XIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled circular>
|
|
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
|
|
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
|
|
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
|
|
<div class="my-4 h-[1px] w-full bg-divider" />
|
|
<div class="flex-1">
|
|
<div v-if="done">
|
|
<p>
|
|
You are done moderating this project!
|
|
<template v-if="futureProjectCount > 0">
|
|
There are
|
|
{{ futureProjectCount }} left.
|
|
</template>
|
|
</p>
|
|
</div>
|
|
<div v-else-if="generatedMessage">
|
|
<div>
|
|
<MarkdownEditor
|
|
v-model="message"
|
|
:max-height="400"
|
|
placeholder="No message generated."
|
|
:disabled="false"
|
|
:heading-buttons="false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="isModpackPermissionsStage">
|
|
<ModpackPermissionsFlow
|
|
v-model="modpackJudgements"
|
|
:project-id="project.id"
|
|
@complete="handleModpackPermissionsComplete"
|
|
/>
|
|
</div>
|
|
<div v-else>
|
|
<h2 class="m-0 mb-2 text-lg font-extrabold">
|
|
{{ currentStageObj.title }}
|
|
</h2>
|
|
|
|
<div v-if="currentStageObj.text" class="mb-4">
|
|
<div v-if="stageTextExpanded" class="markdown-body" v-html="stageTextExpanded"></div>
|
|
<div v-else class="markdown-body">Loading stage content...</div>
|
|
</div>
|
|
|
|
<!-- Action components grouped by type -->
|
|
<div class="space-y-4">
|
|
<!-- Button actions group -->
|
|
<div v-if="buttonActions.length > 0" class="button-actions-group">
|
|
<div class="flex flex-wrap gap-2">
|
|
<template v-for="action in buttonActions" :key="getActionKey(action)">
|
|
<ButtonStyled
|
|
:color="isActionSelected(action) ? 'brand' : 'standard'"
|
|
@click="toggleAction(action)"
|
|
>
|
|
<button>
|
|
{{ action.label }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toggle actions group -->
|
|
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
|
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
|
<Checkbox
|
|
:model-value="actionStates[getActionId(action)]?.selected ?? false"
|
|
:label="action.label"
|
|
:description="action.description"
|
|
:disabled="false"
|
|
@update:model-value="toggleAction(action)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Dropdown actions group -->
|
|
<div v-if="dropdownActions.length > 0" class="dropdown-actions-group space-y-3">
|
|
<template v-for="action in dropdownActions" :key="getActionKey(action)">
|
|
<div class="inputs universal-labels">
|
|
<div>
|
|
<label :for="`dropdown-${getActionId(action)}`">
|
|
<span class="label__title">{{ action.label }}</span>
|
|
</label>
|
|
<DropdownSelect
|
|
:max-visible-options="3"
|
|
render-up
|
|
:name="`dropdown-${getActionId(action)}`"
|
|
:options="getVisibleDropdownOptions(action)"
|
|
:model-value="getDropdownValue(action)"
|
|
:placeholder="'Select an option'"
|
|
:disabled="false"
|
|
:display-name="(opt: any) => opt?.label || 'Unknown option'"
|
|
@update:model-value="
|
|
(selected: any) => selectDropdownOption(action, selected)
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Multi-select chips actions group -->
|
|
<div v-if="multiSelectActions.length > 0" class="multi-select-actions-group space-y-3">
|
|
<template v-for="action in multiSelectActions" :key="getActionKey(action)">
|
|
<div>
|
|
<div class="mb-2 font-semibold">{{ action.label }}</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<ButtonStyled
|
|
v-for="(option, optIndex) in getVisibleMultiSelectOptions(action)"
|
|
:key="`${getActionId(action)}-chip-${optIndex}`"
|
|
:color="isChipSelected(action, optIndex) ? 'brand' : 'standard'"
|
|
@click="toggleChip(action, optIndex)"
|
|
>
|
|
<button>
|
|
{{ option.label }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isAnyVisibleInputs" class="my-4 h-[1px] w-full bg-divider" />
|
|
|
|
<!-- Additional text inputs -->
|
|
<div class="space-y-4">
|
|
<template v-for="action in visibleActions" :key="`inputs-${getActionKey(action)}`">
|
|
<div
|
|
v-if="action.relevantExtraInput && isActionSelected(action)"
|
|
class="inputs universal-labels"
|
|
>
|
|
<div
|
|
v-for="(input, inputIndex) in getVisibleInputs(action, actionStates)"
|
|
:key="`input-${getActionId(action)}-${inputIndex}`"
|
|
class="mt-2"
|
|
>
|
|
<template v-if="input.large">
|
|
<label :for="`input-${getActionId(action)}-${inputIndex}`">
|
|
<span class="label__title">
|
|
{{ input.label }}
|
|
<span v-if="input.required" class="required">*</span>
|
|
</span>
|
|
</label>
|
|
<MarkdownEditor
|
|
:id="`input-${getActionId(action)}-${inputIndex}`"
|
|
v-model="textInputValues[`${getActionId(action)}-${inputIndex}`]"
|
|
:placeholder="input.placeholder"
|
|
:max-height="300"
|
|
:disabled="false"
|
|
:heading-buttons="false"
|
|
@input="persistState"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<label :for="`input-${getActionId(action)}-${inputIndex}`">
|
|
<span class="label__title">
|
|
{{ input.label }}
|
|
<span v-if="input.required" class="required">*</span>
|
|
</span>
|
|
</label>
|
|
<input
|
|
:id="`input-${getActionId(action)}-${inputIndex}`"
|
|
v-model="textInputValues[`${getActionId(action)}-${inputIndex}`]"
|
|
type="text"
|
|
:placeholder="input.placeholder"
|
|
autocomplete="off"
|
|
@input="persistState"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stage control buttons -->
|
|
<div class="mt-auto">
|
|
<div
|
|
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
|
|
<button @click="goToNextProject">
|
|
<XIcon aria-hidden="true" />
|
|
Skip
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div v-if="done">
|
|
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
|
|
<button @click="goToNextProject">
|
|
<RightArrowIcon aria-hidden="true" />
|
|
Next Project
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled v-else color="brand">
|
|
<button @click="exitModeration">
|
|
<CheckIcon aria-hidden="true" />
|
|
Done
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
|
|
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
|
<OverflowMenu :options="stageOptions" class="bg-transparent p-0">
|
|
<ButtonStyled circular>
|
|
<button v-tooltip="`Stages`">
|
|
<ListBulletedIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
|
|
<template
|
|
v-for="opt in stageOptions.filter(
|
|
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
|
|
)"
|
|
#[opt.id]
|
|
:key="opt.id"
|
|
>
|
|
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
|
|
{{ opt.text }}
|
|
</template>
|
|
</OverflowMenu>
|
|
<ButtonStyled>
|
|
<button @click="goBackToStages">
|
|
<LeftArrowIcon aria-hidden="true" />
|
|
Edit
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="red">
|
|
<button @click="sendMessage('rejected')">
|
|
<XIcon aria-hidden="true" />
|
|
Reject
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="orange">
|
|
<button @click="sendMessage('withheld')">
|
|
<EyeOffIcon aria-hidden="true" />
|
|
Withhold
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="green">
|
|
<button @click="sendMessage('approved')">
|
|
<CheckIcon aria-hidden="true" />
|
|
Approve
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
|
|
<div v-else class="flex items-center gap-2">
|
|
<OverflowMenu
|
|
v-if="!generatedMessage"
|
|
:options="stageOptions"
|
|
class="bg-transparent p-0"
|
|
>
|
|
<ButtonStyled circular>
|
|
<button v-tooltip="`Stages`">
|
|
<ListBulletedIcon />
|
|
</button>
|
|
</ButtonStyled>
|
|
|
|
<template
|
|
v-for="opt in stageOptions.filter(
|
|
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
|
|
)"
|
|
#[opt.id]
|
|
:key="opt.id"
|
|
>
|
|
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
|
|
{{ opt.text }}
|
|
</template>
|
|
</OverflowMenu>
|
|
<ButtonStyled>
|
|
<button :disabled="!hasValidPreviousStage" @click="previousStage">
|
|
<LeftArrowIcon aria-hidden="true" /> Previous
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled v-if="!isLastVisibleStage" color="brand">
|
|
<button @click="nextStage"><RightArrowIcon aria-hidden="true" /> Next</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled v-else color="brand" :disabled="loadingMessage">
|
|
<button @click="generateMessage">
|
|
<CheckIcon aria-hidden="true" />
|
|
{{ loadingMessage ? "Generating..." : "Generate Message" }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Collapsible>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {
|
|
LeftArrowIcon,
|
|
RightArrowIcon,
|
|
DropdownIcon,
|
|
XIcon,
|
|
ScaleIcon,
|
|
ListBulletedIcon,
|
|
FileTextIcon,
|
|
BrushCleaningIcon,
|
|
CheckIcon,
|
|
KeyboardIcon,
|
|
EyeOffIcon,
|
|
} from "@modrinth/assets";
|
|
import {
|
|
checklist,
|
|
getActionIdForStage,
|
|
initializeActionState,
|
|
getActionMessage,
|
|
findMatchingVariant,
|
|
processMessage,
|
|
getVisibleInputs,
|
|
serializeActionStates,
|
|
deserializeActionStates,
|
|
kebabToTitleCase,
|
|
flattenProjectVariables,
|
|
expandVariables,
|
|
handleKeybind,
|
|
keybinds,
|
|
} from "@modrinth/moderation";
|
|
import {
|
|
ButtonStyled,
|
|
Collapsible,
|
|
OverflowMenu,
|
|
type OverflowMenuOption,
|
|
Checkbox,
|
|
DropdownSelect,
|
|
MarkdownEditor,
|
|
} from "@modrinth/ui";
|
|
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
|
|
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
|
import type {
|
|
Action,
|
|
MultiSelectChipsAction,
|
|
DropdownAction,
|
|
ButtonAction,
|
|
ToggleAction,
|
|
ConditionalButtonAction,
|
|
Stage,
|
|
} from "@modrinth/moderation";
|
|
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
|
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
|
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
|
|
import prettier from "prettier";
|
|
|
|
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
project: Project;
|
|
futureProjectIds?: string[];
|
|
collapsed: boolean;
|
|
}>(),
|
|
{
|
|
futureProjectIds: () => [] as string[],
|
|
},
|
|
);
|
|
|
|
const variables = computed(() => {
|
|
return flattenProjectVariables(props.project);
|
|
});
|
|
|
|
const futureProjectCount = computed(() => {
|
|
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
|
return ids.length;
|
|
});
|
|
|
|
const modpackPermissionsComplete = ref(false);
|
|
const modpackJudgements = ref<ModerationJudgements>({});
|
|
const isModpackPermissionsStage = computed(() => {
|
|
return currentStageObj.value.id === "modpack-permissions";
|
|
});
|
|
|
|
const message = ref("");
|
|
const generatedMessage = ref(false);
|
|
const loadingMessage = ref(false);
|
|
const done = ref(false);
|
|
|
|
function handleModpackPermissionsComplete() {
|
|
modpackPermissionsComplete.value = true;
|
|
nextStage();
|
|
}
|
|
|
|
const emit = defineEmits<{
|
|
exit: [];
|
|
toggleCollapsed: [];
|
|
}>();
|
|
|
|
function resetProgress() {
|
|
currentStage.value = findFirstValidStage();
|
|
actionStates.value = {};
|
|
textInputValues.value = {};
|
|
|
|
done.value = false;
|
|
generatedMessage.value = false;
|
|
message.value = "";
|
|
loadingMessage.value = false;
|
|
|
|
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
|
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
|
modpackPermissionsComplete.value = false;
|
|
modpackJudgements.value = {};
|
|
|
|
initializeAllStages();
|
|
}
|
|
|
|
function findFirstValidStage(): number {
|
|
for (let i = 0; i < checklist.length; i++) {
|
|
if (shouldShowStageIndex(i)) {
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const currentStageObj = computed(() => checklist[currentStage.value]);
|
|
const currentStage = useLocalStorage(`moderation-stage-${props.project.slug}`, () =>
|
|
findFirstValidStage(),
|
|
);
|
|
|
|
const stageTextExpanded = computedAsync(async () => {
|
|
const stageIndex = currentStage.value;
|
|
const stage = checklist[stageIndex];
|
|
if (stage.text) {
|
|
return renderHighlightedString(
|
|
expandVariables(await stage.text(props.project), props.project, variables.value),
|
|
);
|
|
}
|
|
return null;
|
|
}, null);
|
|
|
|
interface ActionState {
|
|
selected: boolean;
|
|
value?: any;
|
|
}
|
|
|
|
const persistedActionStates = useLocalStorage(
|
|
`moderation-actions-${props.project.slug}`,
|
|
{},
|
|
{
|
|
serializer: {
|
|
read: (v: any) => (v ? deserializeActionStates(v) : {}),
|
|
write: (v: any) => serializeActionStates(v),
|
|
},
|
|
},
|
|
);
|
|
|
|
const router = useRouter();
|
|
|
|
const persistedTextInputs = useLocalStorage(
|
|
`moderation-inputs-${props.project.slug}`,
|
|
{} as Record<string, string>,
|
|
);
|
|
|
|
const actionStates = ref<Record<string, ActionState>>(persistedActionStates.value);
|
|
const textInputValues = ref<Record<string, string>>(persistedTextInputs.value);
|
|
|
|
const persistState = () => {
|
|
persistedActionStates.value = actionStates.value;
|
|
persistedTextInputs.value = textInputValues.value;
|
|
};
|
|
|
|
watch(actionStates, persistState, { deep: true });
|
|
watch(textInputValues, persistState, { deep: true });
|
|
|
|
interface MessagePart {
|
|
weight: number;
|
|
content: string;
|
|
actionId: string;
|
|
stageIndex: number;
|
|
}
|
|
|
|
function handleKeybinds(event: KeyboardEvent) {
|
|
const focusedActionIndex = ref<number | null>(null);
|
|
|
|
handleKeybind(
|
|
event,
|
|
{
|
|
project: props.project,
|
|
state: {
|
|
currentStage: currentStage.value,
|
|
totalStages: checklist.length,
|
|
currentStageId: currentStageObj.value.id,
|
|
currentStageTitle: currentStageObj.value.title,
|
|
|
|
isCollapsed: props.collapsed,
|
|
isDone: done.value,
|
|
hasGeneratedMessage: generatedMessage.value,
|
|
isLoadingMessage: loadingMessage.value,
|
|
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
|
|
|
futureProjectCount: futureProjectCount.value,
|
|
visibleActionsCount: visibleActions.value.length,
|
|
|
|
focusedActionIndex: focusedActionIndex.value,
|
|
focusedActionType:
|
|
focusedActionIndex.value !== null
|
|
? (visibleActions.value[focusedActionIndex.value]?.type as any)
|
|
: null,
|
|
},
|
|
actions: {
|
|
tryGoNext: nextStage,
|
|
tryGoBack: previousStage,
|
|
tryGenerateMessage: generateMessage,
|
|
trySkipProject: goToNextProject,
|
|
|
|
tryToggleCollapse: () => emit("toggleCollapsed"),
|
|
tryResetProgress: resetProgress,
|
|
tryExitModeration: () => emit("exit"),
|
|
|
|
tryApprove: () => sendMessage("approved"),
|
|
tryReject: () => sendMessage("rejected"),
|
|
tryWithhold: () => sendMessage("withheld"),
|
|
tryEditMessage: goBackToStages,
|
|
|
|
tryToggleAction: (actionIndex: number) => {
|
|
const action = visibleActions.value[actionIndex];
|
|
if (action) {
|
|
toggleAction(action);
|
|
}
|
|
},
|
|
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => {
|
|
const action = visibleActions.value[actionIndex] as DropdownAction;
|
|
if (action && action.type === "dropdown") {
|
|
const visibleOptions = getVisibleDropdownOptions(action);
|
|
if (optionIndex < visibleOptions.length) {
|
|
selectDropdownOption(action, visibleOptions[optionIndex]);
|
|
}
|
|
}
|
|
},
|
|
tryToggleChip: (actionIndex: number, chipIndex: number) => {
|
|
const action = visibleActions.value[actionIndex] as MultiSelectChipsAction;
|
|
if (action && action.type === "multi-select-chips") {
|
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
|
if (chipIndex < visibleOptions.length) {
|
|
toggleChip(action, chipIndex);
|
|
}
|
|
}
|
|
},
|
|
|
|
tryFocusNextAction: () => {
|
|
if (visibleActions.value.length === 0) return;
|
|
if (focusedActionIndex.value === null) {
|
|
focusedActionIndex.value = 0;
|
|
} else {
|
|
focusedActionIndex.value = (focusedActionIndex.value + 1) % visibleActions.value.length;
|
|
}
|
|
},
|
|
tryFocusPreviousAction: () => {
|
|
if (visibleActions.value.length === 0) return;
|
|
if (focusedActionIndex.value === null) {
|
|
focusedActionIndex.value = visibleActions.value.length - 1;
|
|
} else {
|
|
focusedActionIndex.value =
|
|
focusedActionIndex.value === 0
|
|
? visibleActions.value.length - 1
|
|
: focusedActionIndex.value - 1;
|
|
}
|
|
},
|
|
tryActivateFocusedAction: () => {
|
|
if (focusedActionIndex.value === null) return;
|
|
const action = visibleActions.value[focusedActionIndex.value];
|
|
if (!action) return;
|
|
|
|
if (
|
|
action.type === "button" ||
|
|
action.type === "conditional-button" ||
|
|
action.type === "toggle"
|
|
) {
|
|
toggleAction(action);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
keybinds,
|
|
);
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("keydown", handleKeybinds);
|
|
initializeAllStages();
|
|
});
|
|
|
|
function initializeAllStages() {
|
|
checklist.forEach((stage, stageIndex) => {
|
|
initializeStageActions(stage, stageIndex);
|
|
});
|
|
}
|
|
|
|
function initializeCurrentStage() {
|
|
initializeStageActions(currentStageObj.value, currentStage.value);
|
|
}
|
|
|
|
watch(
|
|
currentStage,
|
|
(newIndex) => {
|
|
const stage = checklist[newIndex];
|
|
if (stage?.navigate) {
|
|
router.push(`/${props.project.project_type}/${props.project.slug}${stage.navigate}`);
|
|
}
|
|
|
|
initializeCurrentStage();
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
function initializeStageActions(stage: Stage, stageIndex: number) {
|
|
stage.actions.forEach((action, index) => {
|
|
const actionId = getActionIdForStage(action, stageIndex, index);
|
|
if (!actionStates.value[actionId]) {
|
|
actionStates.value[actionId] = initializeActionState(action);
|
|
}
|
|
});
|
|
|
|
stage.actions.forEach((action) => {
|
|
if (action.enablesActions) {
|
|
action.enablesActions.forEach((enabledAction, index) => {
|
|
const actionId = getActionIdForStage(enabledAction, currentStage.value, index);
|
|
if (!actionStates.value[actionId]) {
|
|
actionStates.value[actionId] = initializeActionState(enabledAction);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function getActionId(action: Action, index?: number): string {
|
|
return getActionIdForStage(action, currentStage.value, index);
|
|
}
|
|
|
|
function getActionKey(action: Action): string {
|
|
const index = visibleActions.value.indexOf(action);
|
|
return `${currentStage.value}-${index}-${getActionId(action)}`;
|
|
}
|
|
|
|
const visibleActions = computed(() => {
|
|
const selectedActionIds = Object.entries(actionStates.value)
|
|
.filter(([_, state]) => state.selected)
|
|
.map(([id]) => id);
|
|
|
|
const allActions: Action[] = [];
|
|
const actionSources = new Map<Action, { enabledBy?: Action; actionIndex?: number }>();
|
|
|
|
currentStageObj.value.actions.forEach((action, actionIndex) => {
|
|
if (shouldShowAction(action)) {
|
|
allActions.push(action);
|
|
actionSources.set(action, { actionIndex });
|
|
|
|
if (action.enablesActions) {
|
|
action.enablesActions.forEach((enabledAction) => {
|
|
if (shouldShowAction(enabledAction)) {
|
|
allActions.push(enabledAction);
|
|
actionSources.set(enabledAction, { enabledBy: action, actionIndex });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return allActions.filter((action) => {
|
|
const source = actionSources.get(action);
|
|
|
|
if (source?.enabledBy) {
|
|
const enablerId = getActionId(source.enabledBy, source.actionIndex);
|
|
if (!selectedActionIds.includes(enablerId)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const disabledByOthers = currentStageObj.value.actions.some((otherAction, otherIndex) => {
|
|
const otherId = getActionId(otherAction, otherIndex);
|
|
return (
|
|
selectedActionIds.includes(otherId) &&
|
|
otherAction.disablesActions?.includes(
|
|
action.id || `action-${currentStage.value}-${source?.actionIndex}`,
|
|
)
|
|
);
|
|
});
|
|
|
|
return !disabledByOthers;
|
|
});
|
|
});
|
|
|
|
const buttonActions = computed(() =>
|
|
visibleActions.value.filter(
|
|
(action) => action.type === "button" || action.type === "conditional-button",
|
|
),
|
|
);
|
|
|
|
const toggleActions = computed(() =>
|
|
visibleActions.value.filter((action) => action.type === "toggle"),
|
|
);
|
|
|
|
const dropdownActions = computed(() =>
|
|
visibleActions.value.filter((action) => action.type === "dropdown"),
|
|
);
|
|
|
|
const multiSelectActions = computed(() =>
|
|
visibleActions.value.filter((action) => action.type === "multi-select-chips"),
|
|
);
|
|
|
|
function getDropdownValue(action: DropdownAction) {
|
|
const actionId = getActionId(action);
|
|
const visibleOptions = getVisibleDropdownOptions(action);
|
|
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
|
|
|
const allOptions = action.options;
|
|
const storedOption = allOptions[currentValue];
|
|
|
|
if (storedOption && visibleOptions.includes(storedOption)) {
|
|
return storedOption;
|
|
}
|
|
|
|
return visibleOptions[0] || null;
|
|
}
|
|
|
|
function isActionSelected(action: Action): boolean {
|
|
const actionId = getActionId(action);
|
|
return actionStates.value[actionId]?.selected || false;
|
|
}
|
|
|
|
function toggleAction(action: Action) {
|
|
const actionId = getActionId(action);
|
|
const state = actionStates.value[actionId];
|
|
if (state) {
|
|
state.selected = !state.selected;
|
|
persistState();
|
|
}
|
|
}
|
|
|
|
function selectDropdownOption(action: DropdownAction, selected: any) {
|
|
const actionId = getActionId(action);
|
|
const state = actionStates.value[actionId];
|
|
if (state && selected !== undefined && selected !== null) {
|
|
const optionIndex = action.options.findIndex(
|
|
(opt) => opt === selected || (opt?.label && selected?.label && opt.label === selected.label),
|
|
);
|
|
|
|
if (optionIndex !== -1) {
|
|
state.value = optionIndex;
|
|
state.selected = true;
|
|
persistState();
|
|
}
|
|
}
|
|
}
|
|
|
|
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
|
const actionId = getActionId(action);
|
|
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
|
|
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
|
const visibleOption = visibleOptions[optionIndex];
|
|
const originalIndex = action.options.findIndex((opt) => opt === visibleOption);
|
|
|
|
return selectedSet?.has(originalIndex) || false;
|
|
}
|
|
|
|
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
|
const actionId = getActionId(action);
|
|
const state = actionStates.value[actionId];
|
|
if (state && state.value instanceof Set) {
|
|
const visibleOptions = getVisibleMultiSelectOptions(action);
|
|
const visibleOption = visibleOptions[optionIndex];
|
|
const originalIndex = action.options.findIndex((opt) => opt === visibleOption);
|
|
|
|
if (originalIndex !== -1) {
|
|
if (state.value.has(originalIndex)) {
|
|
state.value.delete(originalIndex);
|
|
} else {
|
|
state.value.add(originalIndex);
|
|
}
|
|
state.selected = state.value.size > 0;
|
|
persistState();
|
|
}
|
|
}
|
|
}
|
|
|
|
const isAnyVisibleInputs = computed(() => {
|
|
return visibleActions.value.some((action) => {
|
|
const visibleInputs = getVisibleInputs(action, actionStates.value);
|
|
return visibleInputs.length > 0 && isActionSelected(action);
|
|
});
|
|
});
|
|
|
|
async function assembleFullMessage() {
|
|
const messageParts: MessagePart[] = [];
|
|
|
|
for (let stageIndex = 0; stageIndex < checklist.length; stageIndex++) {
|
|
const stage = checklist[stageIndex];
|
|
|
|
await processStageActions(stage, stageIndex, messageParts);
|
|
}
|
|
|
|
messageParts.sort((a, b) => a.weight - b.weight);
|
|
|
|
const finalMessage = expandVariables(
|
|
messageParts
|
|
.map((part) => part.content)
|
|
.filter((content) => content.trim().length > 0)
|
|
.join("\n\n"),
|
|
props.project,
|
|
);
|
|
|
|
return finalMessage;
|
|
}
|
|
|
|
async function processStageActions(stage: Stage, stageIndex: number, messageParts: MessagePart[]) {
|
|
const selectedActionIds = Object.entries(actionStates.value)
|
|
.filter(([_, state]) => state.selected)
|
|
.map(([id]) => id);
|
|
|
|
for (let actionIndex = 0; actionIndex < stage.actions.length; actionIndex++) {
|
|
const action = stage.actions[actionIndex];
|
|
const actionId = getActionIdForStage(action, stageIndex, actionIndex);
|
|
const state = actionStates.value[actionId];
|
|
|
|
if (!state?.selected) continue;
|
|
|
|
await processAction(action, actionId, state, selectedActionIds, stageIndex, messageParts);
|
|
|
|
if (action.enablesActions) {
|
|
for (let enabledIndex = 0; enabledIndex < action.enablesActions.length; enabledIndex++) {
|
|
const enabledAction = action.enablesActions[enabledIndex];
|
|
const enabledActionId = getActionIdForStage(
|
|
enabledAction,
|
|
stageIndex,
|
|
actionIndex,
|
|
enabledIndex,
|
|
);
|
|
const enabledState = actionStates.value[enabledActionId];
|
|
|
|
if (enabledState?.selected) {
|
|
await processAction(
|
|
enabledAction,
|
|
enabledActionId,
|
|
enabledState,
|
|
selectedActionIds,
|
|
stageIndex,
|
|
messageParts,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function processAction(
|
|
action: Action,
|
|
actionId: string,
|
|
state: ActionState,
|
|
selectedActionIds: string[],
|
|
stageIndex: number,
|
|
messageParts: MessagePart[],
|
|
) {
|
|
const allValidActionIds: string[] = [];
|
|
checklist.forEach((stage, stageIdx) => {
|
|
stage.actions.forEach((stageAction, actionIdx) => {
|
|
allValidActionIds.push(getActionIdForStage(stageAction, stageIdx, actionIdx));
|
|
if (stageAction.enablesActions) {
|
|
stageAction.enablesActions.forEach((enabledAction, enabledIdx) => {
|
|
allValidActionIds.push(
|
|
getActionIdForStage(enabledAction, stageIdx, actionIdx, enabledIdx),
|
|
);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (action.type === "button" || action.type === "toggle") {
|
|
const buttonAction = action as ButtonAction | ToggleAction;
|
|
const message = await getActionMessage(buttonAction, selectedActionIds, allValidActionIds);
|
|
if (message) {
|
|
messageParts.push({
|
|
weight: buttonAction.weight,
|
|
content: processMessage(message, action, stageIndex, textInputValues.value),
|
|
actionId,
|
|
stageIndex,
|
|
});
|
|
}
|
|
} else if (action.type === "conditional-button") {
|
|
const conditionalAction = action as ConditionalButtonAction;
|
|
const matchingVariant = findMatchingVariant(
|
|
conditionalAction.messageVariants,
|
|
selectedActionIds,
|
|
allValidActionIds,
|
|
stageIndex,
|
|
);
|
|
|
|
let message: string;
|
|
let weight: number;
|
|
|
|
if (matchingVariant) {
|
|
message = (await matchingVariant.message()) as string;
|
|
weight = matchingVariant.weight;
|
|
} else if (conditionalAction.fallbackMessage) {
|
|
message = (await conditionalAction.fallbackMessage()) as string;
|
|
weight = conditionalAction.fallbackWeight ?? 0;
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
messageParts.push({
|
|
weight,
|
|
content: processMessage(message, action, stageIndex, textInputValues.value),
|
|
actionId,
|
|
stageIndex,
|
|
});
|
|
} else if (action.type === "dropdown") {
|
|
const dropdownAction = action as DropdownAction;
|
|
const selectedIndex = state.value ?? 0;
|
|
const selectedOption = dropdownAction.options[selectedIndex];
|
|
|
|
if (selectedOption && "message" in selectedOption && "weight" in selectedOption) {
|
|
const message = (await selectedOption.message()) as string;
|
|
messageParts.push({
|
|
weight: selectedOption.weight,
|
|
content: processMessage(message, action, stageIndex, textInputValues.value),
|
|
actionId,
|
|
stageIndex,
|
|
});
|
|
}
|
|
} else if (action.type === "multi-select-chips") {
|
|
const multiSelectAction = action as MultiSelectChipsAction;
|
|
const selectedIndices = state.value as Set<number>;
|
|
|
|
for (const index of selectedIndices) {
|
|
const option = multiSelectAction.options[index];
|
|
if (option && "message" in option && "weight" in option) {
|
|
const message = (await option.message()) as string;
|
|
messageParts.push({
|
|
weight: option.weight,
|
|
content: processMessage(message, action, stageIndex, textInputValues.value),
|
|
actionId: `${actionId}-option-${index}`,
|
|
stageIndex,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function shouldShowStage(stage: Stage): boolean {
|
|
let hasVisibleActions = false;
|
|
|
|
for (const a of stage.actions) {
|
|
if (shouldShowAction(a)) {
|
|
hasVisibleActions = true;
|
|
}
|
|
}
|
|
|
|
if (!hasVisibleActions) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof stage.shouldShow === "function") {
|
|
return stage.shouldShow(props.project);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function shouldShowAction(action: Action): boolean {
|
|
if (typeof action.shouldShow === "function") {
|
|
return action.shouldShow(props.project);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getVisibleDropdownOptions(action: DropdownAction) {
|
|
return action.options.filter((option) => {
|
|
if (typeof option.shouldShow === "function") {
|
|
return option.shouldShow(props.project);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function getVisibleMultiSelectOptions(action: MultiSelectChipsAction) {
|
|
return action.options.filter((option) => {
|
|
if (typeof option.shouldShow === "function") {
|
|
return option.shouldShow(props.project);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function shouldShowStageIndex(stageIndex: number): boolean {
|
|
return shouldShowStage(checklist[stageIndex]);
|
|
}
|
|
|
|
function previousStage() {
|
|
let targetStage = currentStage.value - 1;
|
|
|
|
while (targetStage >= 0) {
|
|
if (shouldShowStageIndex(targetStage)) {
|
|
currentStage.value = targetStage;
|
|
return;
|
|
}
|
|
targetStage--;
|
|
}
|
|
}
|
|
|
|
function nextStage() {
|
|
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
|
addNotification({
|
|
title: "Modpack permissions stage unfinished",
|
|
message: "Please complete the modpack permissions stage before proceeding.",
|
|
type: "error",
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
let targetStage = currentStage.value + 1;
|
|
|
|
while (targetStage < checklist.length) {
|
|
if (shouldShowStageIndex(targetStage)) {
|
|
currentStage.value = targetStage;
|
|
if (!isModpackPermissionsStage.value) {
|
|
modpackPermissionsComplete.value = false;
|
|
}
|
|
|
|
return;
|
|
}
|
|
targetStage++;
|
|
}
|
|
}
|
|
|
|
function goBackToStages() {
|
|
generatedMessage.value = false;
|
|
message.value = "";
|
|
|
|
let targetStage = checklist.length - 1;
|
|
while (targetStage >= 0) {
|
|
if (shouldShowStageIndex(targetStage)) {
|
|
currentStage.value = targetStage;
|
|
return;
|
|
}
|
|
targetStage--;
|
|
}
|
|
}
|
|
|
|
async function generateMessage() {
|
|
if (loadingMessage.value) return;
|
|
|
|
loadingMessage.value = true;
|
|
|
|
router.push(`/${props.project.project_type}/${props.project.slug}/moderation`);
|
|
|
|
try {
|
|
const baseMessage = await assembleFullMessage();
|
|
let fullMessage = baseMessage;
|
|
|
|
if (
|
|
props.project.project_type === "modpack" &&
|
|
Object.keys(modpackJudgements.value).length > 0
|
|
) {
|
|
const modpackMessage = generateModpackMessage(modpackJudgements.value);
|
|
if (modpackMessage) {
|
|
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const formattedMessage = await prettier.format(fullMessage, {
|
|
parser: "markdown",
|
|
printWidth: 80,
|
|
proseWrap: "always",
|
|
tabWidth: 2,
|
|
useTabs: false,
|
|
});
|
|
message.value = formattedMessage;
|
|
} catch (formattingError) {
|
|
console.warn("Failed to format markdown, using original:", formattingError);
|
|
message.value = fullMessage;
|
|
}
|
|
|
|
generatedMessage.value = true;
|
|
} catch (error) {
|
|
console.error("Error generating message:", error);
|
|
addNotification({
|
|
title: "Error generating message",
|
|
message: "Failed to generate moderation message. Please try again.",
|
|
type: "error",
|
|
});
|
|
} finally {
|
|
loadingMessage.value = false;
|
|
}
|
|
}
|
|
|
|
function generateModpackMessage(judgements: ModerationJudgements) {
|
|
const issues = [];
|
|
|
|
const attributeMods = [];
|
|
const noMods = [];
|
|
const permanentNoMods = [];
|
|
const unidentifiedMods = [];
|
|
|
|
for (const [, judgement] of Object.entries(judgements)) {
|
|
if (judgement.status === "with-attribution") {
|
|
attributeMods.push(judgement.file_name);
|
|
} else if (judgement.status === "no") {
|
|
noMods.push(judgement.file_name);
|
|
} else if (judgement.status === "permanent-no") {
|
|
permanentNoMods.push(judgement.file_name);
|
|
} else if (judgement.status === "unidentified") {
|
|
unidentifiedMods.push(judgement.file_name);
|
|
}
|
|
}
|
|
|
|
if (
|
|
attributeMods.length > 0 ||
|
|
noMods.length > 0 ||
|
|
permanentNoMods.length > 0 ||
|
|
unidentifiedMods.length > 0
|
|
) {
|
|
issues.push("## Copyrighted content");
|
|
|
|
if (attributeMods.length > 0) {
|
|
issues.push(
|
|
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
|
|
);
|
|
}
|
|
|
|
if (noMods.length > 0) {
|
|
issues.push(`${finalPermissionMessages.no}\n${noMods.map((mod) => `- ${mod}`).join("\n")}`);
|
|
}
|
|
|
|
if (permanentNoMods.length > 0) {
|
|
issues.push(
|
|
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
|
|
);
|
|
}
|
|
|
|
if (unidentifiedMods.length > 0) {
|
|
issues.push(
|
|
`${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return issues.join("\n\n");
|
|
}
|
|
|
|
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
|
try {
|
|
await useBaseFetch(`project/${props.project.id}`, {
|
|
method: "PATCH",
|
|
body: {
|
|
status,
|
|
},
|
|
});
|
|
|
|
if (message.value) {
|
|
await useBaseFetch(`thread/${props.project.thread_id}`, {
|
|
method: "POST",
|
|
body: {
|
|
body: {
|
|
type: "text",
|
|
body: message.value,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
props.project.project_type === "modpack" &&
|
|
Object.keys(modpackJudgements.value).length > 0
|
|
) {
|
|
await useBaseFetch(`moderation/project`, {
|
|
internal: true,
|
|
method: "POST",
|
|
body: modpackJudgements.value,
|
|
});
|
|
}
|
|
|
|
done.value = true;
|
|
|
|
// Clear local storage for future reviews
|
|
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
|
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
|
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
|
|
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
|
|
actionStates.value = {};
|
|
|
|
addNotification({
|
|
title: "Moderation submitted",
|
|
message: `Project ${status} successfully.`,
|
|
type: "success",
|
|
});
|
|
} catch (error) {
|
|
console.error("Error submitting moderation:", error);
|
|
addNotification({
|
|
title: "Error submitting moderation",
|
|
message: "Failed to submit moderation decision. Please try again.",
|
|
type: "error",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function goToNextProject() {
|
|
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
|
|
|
if (currentIds.length === 0) {
|
|
await navigateTo("/moderation/review");
|
|
return;
|
|
}
|
|
|
|
const nextProjectId = currentIds[0];
|
|
const remainingIds = currentIds.slice(1);
|
|
|
|
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
|
|
|
|
await router.push({
|
|
name: "type-id",
|
|
params: {
|
|
type: "project",
|
|
id: nextProjectId,
|
|
},
|
|
state: {
|
|
showChecklist: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function exitModeration() {
|
|
await navigateTo("/moderation/review");
|
|
}
|
|
|
|
const isLastVisibleStage = computed(() => {
|
|
for (let i = currentStage.value + 1; i < checklist.length; i++) {
|
|
if (shouldShowStageIndex(i)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const hasValidPreviousStage = computed(() => {
|
|
for (let i = currentStage.value - 1; i >= 0; i--) {
|
|
if (shouldShowStageIndex(i)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
const stageOptions = computed<OverflowMenuOption[]>(() => {
|
|
const options = checklist
|
|
.map((stage, index) => {
|
|
if (!shouldShowStage(stage)) return null;
|
|
|
|
return {
|
|
id: String(index),
|
|
action: () => (currentStage.value = index),
|
|
text: stage.id ? kebabToTitleCase(stage.id) : stage.title,
|
|
color: index === currentStage.value && !generatedMessage.value ? "green" : undefined,
|
|
hoverFilled: true,
|
|
icon: stage.icon ? stage.icon : undefined,
|
|
} as OverflowMenuOption;
|
|
})
|
|
.filter((opt): opt is OverflowMenuOption => opt !== null);
|
|
|
|
options.push({
|
|
id: "generate-message",
|
|
action: () => generateMessage(),
|
|
text: "Generate Message",
|
|
color: generatedMessage.value ? "green" : undefined,
|
|
hoverFilled: true,
|
|
icon: CheckIcon,
|
|
} as OverflowMenuOption);
|
|
|
|
return options;
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.moderation-checklist {
|
|
@media (prefers-reduced-motion) {
|
|
transition: none !important;
|
|
}
|
|
|
|
.button-actions-group,
|
|
.toggle-actions-group,
|
|
.dropdown-actions-group,
|
|
.multi-select-actions-group {
|
|
animation: fadeIn 0.2s ease-in-out;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-5px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
}
|
|
</style>
|