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)}`;
}
get hexWithoutAlpha(): string {
const hexString = this.hex;
return hexString.slice(0, 7);
}
private toHsl(): Hsl {
const r = this.red / 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>;
export type CreateLabelDialogFormParams = FormParams;
export type EditLabelDialogFormParams = FormParams;
</script>
<script lang="ts">
@ -24,22 +24,30 @@
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { Label as LabelEntity } from '$lib/label/domain/entity/label';
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 {
title,
triggerButtonText,
disabled,
defaultValues = {
name: '',
color: '#dddddd',
},
onSubmit: createLabel,
}: {
title: string;
triggerButtonText: string;
disabled: boolean;
defaultValues?: FormParams;
onSubmit: (params: FormParams) => Promise<boolean>;
} = $props();
let open = $state(false);
let formData = $state<FormParams>({
name: '',
color: '#dddddd',
});
let formErrors = $state<Partial<Record<keyof FormParams, string>>>({});
let formData: FormParams = $state(defaultValues);
let formErrors: Partial<Record<keyof FormParams, string>> = $state({});
const previewLabel = $derived(
LabelViewModel.fromEntity(
@ -68,51 +76,60 @@
return;
}
formData = {
name: '',
color: '#dddddd',
};
formData = defaultValues;
open = false;
}
</script>
<Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Create</DialogTrigger>
<DialogTrigger class={buttonVariants({ variant: 'default' })} {disabled}>
{triggerButtonText}
</DialogTrigger>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<DialogHeader class="mb-4">
<DialogTitle>Create Label</DialogTitle>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form id="create-label-form" onsubmit={onSubmit} class="space-y-3">
<div>
<Label for="name-input" class="pb-2">Name</Label>
<Input
id="name-input"
type="text"
aria-invalid={formErrors.name !== undefined}
bind:value={formData.name}
/>
{#if formErrors.name}
<p class="text-sm text-red-500">{formErrors.name}</p>
{/if}
<div class="flex flex-row items-center gap-x-2">
<Input
id="name-input"
type="text"
aria-invalid={formErrors.name !== undefined}
bind:value={formData.name}
/>
<RestoreButton
for="name-input"
defaultValue={defaultValues.name}
postAction={() => (formErrors.name = undefined)}
/>
</div>
<InputError message={formErrors.name} />
</div>
<div>
<div class="w-fit">
<Label for="color-input" class="pb-2">Color</Label>
<Input
id="color-input"
type="color"
class="w-16"
aria-invalid={formErrors.color !== undefined}
bind:value={formData.color}
/>
{#if formErrors.color}
<p class="text-sm text-red-500">{formErrors.color}</p>
{/if}
<div class="flex flex-row items-center gap-x-2">
<Input
id="color-input"
type="color"
class="w-16"
aria-invalid={formErrors.color !== undefined}
bind:value={formData.color}
/>
<RestoreButton
for="color-input"
defaultValue={defaultValues.color}
postAction={() => (formErrors.color = undefined)}
/>
</div>
<InputError message={formErrors.color} />
</div>
</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">
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 TableHeader from '$lib/common/framework/components/ui/table/table-header.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 { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore';
import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore';
import CreateLabelDialog, {
type CreateLabelDialogFormParams,
} from '$lib/label/framework/ui/CreateLabelDialog.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
import EditLabelDialog, {
type EditLabelDialogFormParams,
} from '$lib/label/framework/ui/EditLabelDialog.svelte';
import { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import LabelOverallDashboardTabelRow from '$lib/label/framework/ui/LabelOverallDashboardTabelRow.svelte';
const labelCreatedStore = getContext<LabelCreatedStore>(LabelCreatedStore.name);
const labelCreatedState = $derived($labelCreatedStore);
@ -23,7 +22,7 @@
const labelsListedState = $derived($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 color = colorViewModel.toEntity();
@ -51,9 +50,11 @@
<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</h1>
<CreateLabelDialog
<EditLabelDialog
title="Create Label"
triggerButtonText="Create"
disabled={labelCreatedState.isLoading()}
onSubmit={onCreateLabelDialogSubmit}
{onSubmit}
/>
</div>
<Table>
@ -68,21 +69,7 @@
<TableBody>
{#if labelsListedState.isSuccess()}
{#each labelsListedState.data as label (label.id)}
<TableRow>
<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>
<LabelOverallDashboardTabelRow {label} />
{/each}
{/if}
</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">
import { Container } from '$lib/container';
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';
const container = getContext<Container>(Container.name);
@ -9,4 +9,4 @@
setContext(ImageUploadedStore.name, store);
</script>
<ImageManagementPage />
<ImageOverallDashboardPage />

View File

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

View File

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

View File

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

View File

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