feat: add label and post management dashboard pages with CRUD functionality
Some checks failed
Frontend CI / build (push) Failing after 1m7s

This commit is contained in:
SquidSpirit 2025-10-15 20:42:00 +08:00
parent 4f23151439
commit 300ae2dc05
15 changed files with 218 additions and 64 deletions

View File

@ -0,0 +1,5 @@
<script lang="ts">
const { message }: { message?: string | null } = $props();
</script>
<p class="text-sm text-destructive" hidden={!message}>{message}</p>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { Button } from '$lib/common/framework/components/ui/button';
import { onMount } from 'svelte';
const {
for: htmlFor,
defaultValue,
postAction,
}: {
for: string;
defaultValue: string;
postAction?: () => void;
} = $props();
let hidden = $state(true);
function onClick() {
const input = document.getElementById(htmlFor) as HTMLInputElement | null;
if (input) {
input.value = defaultValue;
input.dispatchEvent(new Event('input'));
input.dispatchEvent(new Event('change'));
}
postAction?.();
}
onMount(() => {
const input = document.getElementById(htmlFor) as HTMLInputElement | null;
hidden = input?.value === defaultValue;
input?.addEventListener('input', () => {
hidden = input?.value === defaultValue;
});
});
</script>
<Button variant="ghost" size="icon-sm" {hidden} onclick={onClick}>
<i class="fa-solid fa-rotate-left"></i>
</Button>

View File

@ -78,6 +78,11 @@ export class ColorViewModel {
return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`; return `#${toHex(this.red)}${toHex(this.green)}${toHex(this.blue)}${toHex(this.alpha)}`;
} }
get hexWithoutAlpha(): string {
const hexString = this.hex;
return hexString.slice(0, 7);
}
private toHsl(): Hsl { private toHsl(): Hsl {
const r = this.red / 255; const r = this.red / 255;
const g = this.green / 255; const g = this.green / 255;

View File

@ -0,0 +1,10 @@
<script lang="ts">
import type { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
const { color }: { color: ColorViewModel } = $props();
</script>
<div class="flex items-center gap-2">
<div class="size-4 rounded-full" style="background-color: {color.hex};"></div>
<span class="font-mono text-sm">{color.hex}</span>
</div>

View File

@ -7,7 +7,7 @@
}); });
type FormParams = z.infer<typeof formSchema>; type FormParams = z.infer<typeof formSchema>;
export type CreateLabelDialogFormParams = FormParams; export type EditLabelDialogFormParams = FormParams;
</script> </script>
<script lang="ts"> <script lang="ts">
@ -24,22 +24,30 @@
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel'; import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { Label as LabelEntity } from '$lib/label/domain/entity/label'; import { Label as LabelEntity } from '$lib/label/domain/entity/label';
import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel'; import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
import RestoreButton from '$lib/common/framework/ui/RestoreButton.svelte';
import InputError from '$lib/common/framework/ui/InputError.svelte';
const { const {
title,
triggerButtonText,
disabled, disabled,
defaultValues = {
name: '',
color: '#dddddd',
},
onSubmit: createLabel, onSubmit: createLabel,
}: { }: {
title: string;
triggerButtonText: string;
disabled: boolean; disabled: boolean;
defaultValues?: FormParams;
onSubmit: (params: FormParams) => Promise<boolean>; onSubmit: (params: FormParams) => Promise<boolean>;
} = $props(); } = $props();
let open = $state(false); let open = $state(false);
let formData = $state<FormParams>({ let formData: FormParams = $state(defaultValues);
name: '', let formErrors: Partial<Record<keyof FormParams, string>> = $state({});
color: '#dddddd',
});
let formErrors = $state<Partial<Record<keyof FormParams, string>>>({});
const previewLabel = $derived( const previewLabel = $derived(
LabelViewModel.fromEntity( LabelViewModel.fromEntity(
@ -68,51 +76,60 @@
return; return;
} }
formData = { formData = defaultValues;
name: '',
color: '#dddddd',
};
open = false; open = false;
} }
</script> </script>
<Dialog bind:open> <Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Create</DialogTrigger> <DialogTrigger class={buttonVariants({ variant: 'default' })} {disabled}>
{triggerButtonText}
</DialogTrigger>
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()} onEscapeKeydown={(e) => e.preventDefault()}
> >
<DialogHeader class="mb-4"> <DialogHeader class="mb-4">
<DialogTitle>Create Label</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
<form id="create-label-form" onsubmit={onSubmit} class="space-y-3"> <form id="create-label-form" onsubmit={onSubmit} class="space-y-3">
<div> <div>
<Label for="name-input" class="pb-2">Name</Label> <Label for="name-input" class="pb-2">Name</Label>
<Input <div class="flex flex-row items-center gap-x-2">
id="name-input" <Input
type="text" id="name-input"
aria-invalid={formErrors.name !== undefined} type="text"
bind:value={formData.name} aria-invalid={formErrors.name !== undefined}
/> bind:value={formData.name}
{#if formErrors.name} />
<p class="text-sm text-red-500">{formErrors.name}</p> <RestoreButton
{/if} for="name-input"
defaultValue={defaultValues.name}
postAction={() => (formErrors.name = undefined)}
/>
</div>
<InputError message={formErrors.name} />
</div> </div>
<div> <div class="w-fit">
<Label for="color-input" class="pb-2">Color</Label> <Label for="color-input" class="pb-2">Color</Label>
<Input <div class="flex flex-row items-center gap-x-2">
id="color-input" <Input
type="color" id="color-input"
class="w-16" type="color"
aria-invalid={formErrors.color !== undefined} class="w-16"
bind:value={formData.color} aria-invalid={formErrors.color !== undefined}
/> bind:value={formData.color}
{#if formErrors.color} />
<p class="text-sm text-red-500">{formErrors.color}</p> <RestoreButton
{/if} for="color-input"
defaultValue={defaultValues.color}
postAction={() => (formErrors.color = undefined)}
/>
</div>
<InputError message={formErrors.color} />
</div> </div>
</form> </form>

View File

@ -0,0 +1,66 @@
<script lang="ts">
import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore';
import ColorCode from '$lib/label/framework/ui/ColorCode.svelte';
import EditLabelDialog, {
type EditLabelDialogFormParams,
} from '$lib/label/framework/ui/EditLabelDialog.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
import { getContext, onMount } from 'svelte';
const { id }: { id: number } = $props();
const labelLoadedStore = getContext<LabelLoadedStore>(LabelLoadedStore.name);
const labelLoadedState = $derived($labelLoadedStore);
const { trigger: loadLabel } = labelLoadedStore;
const label = $derived(labelLoadedState.data);
const formDefaultValues: EditLabelDialogFormParams | null = $derived.by(() => {
if (labelLoadedState.data === null) {
return null;
}
return {
name: labelLoadedState.data.name,
color: labelLoadedState.data.color.hexWithoutAlpha,
};
});
async function onSubmit(params: EditLabelDialogFormParams): Promise<boolean> {
return true;
}
onMount(() => loadLabel(id));
</script>
<div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Label Details</h1>
<EditLabelDialog
title="Update Label"
triggerButtonText="Edit"
disabled={!labelLoadedState.isSuccess()}
defaultValues={formDefaultValues ?? undefined}
{onSubmit}
/>
</div>
<div class="grid grid-cols-[auto_1fr] gap-x-8 gap-y-3">
<span class="font-medium whitespace-nowrap">ID</span>
<span>{label?.id}</span>
<span class="font-medium whitespace-nowrap">Name</span>
<span>{label?.name}</span>
<span class="font-medium whitespace-nowrap">Color</span>
<div class="content-center">
{#if label}
<ColorCode color={label.color} />
{/if}
</div>
<span class="font-medium whitespace-nowrap">Preview</span>
<div class="content-center">
{#if label}
<PostLabel {label} />
{/if}
</div>
</div>
</div>

View File

@ -1,19 +1,18 @@
<script lang="ts"> <script lang="ts">
import TableBody from '$lib/common/framework/components/ui/table/table-body.svelte'; import TableBody from '$lib/common/framework/components/ui/table/table-body.svelte';
import TableCell from '$lib/common/framework/components/ui/table/table-cell.svelte';
import TableHead from '$lib/common/framework/components/ui/table/table-head.svelte'; import TableHead from '$lib/common/framework/components/ui/table/table-head.svelte';
import TableHeader from '$lib/common/framework/components/ui/table/table-header.svelte'; import TableHeader from '$lib/common/framework/components/ui/table/table-header.svelte';
import TableRow from '$lib/common/framework/components/ui/table/table-row.svelte'; import TableRow from '$lib/common/framework/components/ui/table/table-row.svelte';
import Table from '$lib/common/framework/components/ui/table/table.svelte'; import Table from '$lib/common/framework/components/ui/table/table.svelte';
import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore'; import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore';
import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore'; import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore';
import CreateLabelDialog, { import EditLabelDialog, {
type CreateLabelDialogFormParams, type EditLabelDialogFormParams,
} from '$lib/label/framework/ui/CreateLabelDialog.svelte'; } from '$lib/label/framework/ui/EditLabelDialog.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel'; import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import LabelOverallDashboardTabelRow from '$lib/label/framework/ui/LabelOverallDashboardTabelRow.svelte';
const labelCreatedStore = getContext<LabelCreatedStore>(LabelCreatedStore.name); const labelCreatedStore = getContext<LabelCreatedStore>(LabelCreatedStore.name);
const labelCreatedState = $derived($labelCreatedStore); const labelCreatedState = $derived($labelCreatedStore);
@ -23,7 +22,7 @@
const labelsListedState = $derived($labelsListedStore); const labelsListedState = $derived($labelsListedStore);
const { trigger: loadLabels } = labelsListedStore; const { trigger: loadLabels } = labelsListedStore;
async function onCreateLabelDialogSubmit(params: CreateLabelDialogFormParams): Promise<boolean> { async function onSubmit(params: EditLabelDialogFormParams): Promise<boolean> {
const colorViewModel = ColorViewModel.fromHex(params.color); const colorViewModel = ColorViewModel.fromHex(params.color);
const color = colorViewModel.toEntity(); const color = colorViewModel.toEntity();
@ -51,9 +50,11 @@
<div class="dashboard-container mb-10"> <div class="dashboard-container mb-10">
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between">
<h1 class="py-16 text-5xl font-bold text-gray-800">Label</h1> <h1 class="py-16 text-5xl font-bold text-gray-800">Label</h1>
<CreateLabelDialog <EditLabelDialog
title="Create Label"
triggerButtonText="Create"
disabled={labelCreatedState.isLoading()} disabled={labelCreatedState.isLoading()}
onSubmit={onCreateLabelDialogSubmit} {onSubmit}
/> />
</div> </div>
<Table> <Table>
@ -68,21 +69,7 @@
<TableBody> <TableBody>
{#if labelsListedState.isSuccess()} {#if labelsListedState.isSuccess()}
{#each labelsListedState.data as label (label.id)} {#each labelsListedState.data as label (label.id)}
<TableRow> <LabelOverallDashboardTabelRow {label} />
<TableCell>{label.id}</TableCell>
<TableCell><span class="text-wrap">{label.name}</span></TableCell>
<TableCell>
<div class="flex items-center gap-2">
<div class="size-4 rounded-full" style="background-color: {label.color.hex};"></div>
<span class="font-mono text-sm">{label.color.hex}</span>
</div>
</TableCell>
<TableCell>
<div>
<PostLabel {label} />
</div>
</TableCell>
</TableRow>
{/each} {/each}
{/if} {/if}
</TableBody> </TableBody>

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { TableCell, TableRow } from '$lib/common/framework/components/ui/table';
import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import ColorCode from '$lib/label/framework/ui/ColorCode.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
const { label }: { label: LabelViewModel } = $props();
</script>
<TableRow>
<TableCell>
<a href={`/dashboard/label/${label.id}`} class="font-medium underline">
{label.id}
</a>
</TableCell>
<TableCell><span class="text-wrap">{label.name}</span></TableCell>
<TableCell>
<ColorCode color={label.color} />
</TableCell>
<TableCell>
<PostLabel {label} />
</TableCell>
</TableRow>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Container } from '$lib/container'; import { Container } from '$lib/container';
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore'; import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
import ImageManagementPage from '$lib/image/framework/ui/ImageManagementPage.svelte'; import ImageOverallDashboardPage from '$lib/image/framework/ui/ImageOverallDashboardPage.svelte';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
const container = getContext<Container>(Container.name); const container = getContext<Container>(Container.name);
@ -9,4 +9,4 @@
setContext(ImageUploadedStore.name, store); setContext(ImageUploadedStore.name, store);
</script> </script>
<ImageManagementPage /> <ImageOverallDashboardPage />

View File

@ -2,7 +2,7 @@
import { Container } from '$lib/container'; import { Container } from '$lib/container';
import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore'; import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore';
import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore'; import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore';
import LabelManagementPage from '$lib/label/framework/ui/LabelManagementPage.svelte'; import LabelOverallDashboardPage from '$lib/label/framework/ui/LabelOverallDashboardPage.svelte';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel'; import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
@ -18,4 +18,4 @@
setContext(LabelsListedStore.name, labelsListedStore); setContext(LabelsListedStore.name, labelsListedStore);
</script> </script>
<LabelManagementPage /> <LabelOverallDashboardPage />

View File

@ -4,14 +4,17 @@
import { Container } from '$lib/container'; import { Container } from '$lib/container';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel'; import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore'; import { LabelLoadedStore } from '$lib/label/adapter/presenter/labelLoadedStore';
import LabelContentDashboardPage from '$lib/label/framework/ui/LabelContentDashboardPage.svelte';
const { data, params }: PageProps = $props(); const { data, params }: PageProps = $props();
const { id } = params; const { id } = params;
const numericId = $derived(Number(id));
const container = getContext<Container>(Container.name); const container = getContext<Container>(Container.name);
const initialData = LabelViewModel.rehydrate(data.dehydratedData!); const initialData = LabelViewModel.rehydrate(data.dehydratedData);
const store = container.createLabelLoadedStore(initialData); const store = container.createLabelLoadedStore(initialData);
setContext(LabelLoadedStore.name, store); setContext(LabelLoadedStore.name, store);
</script> </script>
<div>{id}</div> <LabelContentDashboardPage id={numericId} />

View File

@ -2,7 +2,7 @@
import { Container } from '$lib/container'; import { Container } from '$lib/container';
import { PostCreatedStore } from '$lib/post/adapter/presenter/postCreatedStore'; import { PostCreatedStore } from '$lib/post/adapter/presenter/postCreatedStore';
import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore'; import { PostsListedStore } from '$lib/post/adapter/presenter/postsListedStore';
import PostManagementPage from '$lib/post/framework/ui/PostManagementPage.svelte'; import PostOverallDashboardPage from '$lib/post/framework/ui/PostOverallDashboardPage.svelte';
import { getContext, setContext } from 'svelte'; import { getContext, setContext } from 'svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel'; import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
@ -18,4 +18,4 @@
setContext(PostsListedStore.name, postsListedStore); setContext(PostsListedStore.name, postsListedStore);
</script> </script>
<PostManagementPage /> <PostOverallDashboardPage />

View File

@ -10,7 +10,7 @@
const { id } = params; const { id } = params;
const container = getContext<Container>(Container.name); const container = getContext<Container>(Container.name);
const initialData = PostViewModel.rehydrate(data.dehydratedData!); const initialData = PostViewModel.rehydrate(data.dehydratedData);
const store = container.createPostLoadedStore(initialData); const store = container.createPostLoadedStore(initialData);
setContext(PostLoadedStore.name, store); setContext(PostLoadedStore.name, store);
</script> </script>