BLOG-140 Label management (list and create) #144

Merged
squid merged 6 commits from BLOG-140_label_management into main 2025-10-15 12:21:42 +08:00
24 changed files with 566 additions and 44 deletions
Showing only changes of commit af4f2326ed - Show all commits

View File

@ -1,7 +1,7 @@
import { User } from '$lib/auth/domain/entity/user'; import { User } from '$lib/auth/domain/entity/user';
import z from 'zod'; import z from 'zod';
export const UserResponseSchema = z.object({ export const userResponseSchema = z.object({
id: z.int32(), id: z.int32(),
displayed_name: z.string(), displayed_name: z.string(),
email: z.email(), email: z.email(),
@ -19,7 +19,7 @@ export class UserResponseDto {
} }
static fromJson(json: unknown): UserResponseDto { static fromJson(json: unknown): UserResponseDto {
const parsedJson = UserResponseSchema.parse(json); const parsedJson = userResponseSchema.parse(json);
return new UserResponseDto({ return new UserResponseDto({
id: parsedJson.id, id: parsedJson.id,
displayedName: parsedJson.displayed_name, displayedName: parsedJson.displayed_name,

View File

@ -4,6 +4,15 @@ import { AuthLoadedStore } from '$lib/auth/adapter/presenter/authLoadedStore';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository'; import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase'; import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl'; import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import { LabelRepositoryImpl } from '$lib/label/adapter/gateway/labelRepositoryImpl';
import { LabelCreatedStore } from '$lib/label/adapter/presenter/labelCreatedStore';
import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import { LabelsListedStore } from '$lib/label/adapter/presenter/labelsListedStore';
import type { LabelRepository } from '$lib/label/application/gateway/labelRepository';
import { CreateLabelUseCase } from '$lib/label/application/useCase/createLabelUseCase';
import { GetAllLabelsUseCase } from '$lib/label/application/useCase/getAllLabelsUseCase';
import { LabelApiServiceImpl } from '$lib/label/framework/api/labelApiServiceImpl';
import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService'; import type { ImageApiService } from '$lib/image/adapter/gateway/imageApiService';
import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl'; import { ImageRepositoryImpl } from '$lib/image/adapter/gateway/imageRepositoryImpl';
import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore'; import { ImageUploadedStore } from '$lib/image/adapter/presenter/imageUploadedStore';
@ -48,8 +57,16 @@ export class Container {
return new PostLoadedStore(this.useCases.getPostUseCase, initialData); return new PostLoadedStore(this.useCases.getPostUseCase, initialData);
} }
createPostCreatedStore(initialData?: PostViewModel): PostCreatedStore { createPostCreatedStore(): PostCreatedStore {
return new PostCreatedStore(this.useCases.createPostUseCase, initialData); return new PostCreatedStore(this.useCases.createPostUseCase);
}
createLabelsListedStore(initialData?: readonly LabelViewModel[]): LabelsListedStore {
return new LabelsListedStore(this.useCases.getAllLabelsUseCase, initialData);
}
createLabelCreatedStore(): LabelCreatedStore {
return new LabelCreatedStore(this.useCases.createLabelUseCase);
} }
} }
@ -59,6 +76,7 @@ class ApiServices {
private _authApiService?: AuthApiService; private _authApiService?: AuthApiService;
private _imageApiService?: ImageApiService; private _imageApiService?: ImageApiService;
private _postApiService?: PostApiService; private _postApiService?: PostApiService;
private _labelApiService?: LabelApiService;
constructor(fetchFn: typeof fetch) { constructor(fetchFn: typeof fetch) {
this.fetchFn = fetchFn; this.fetchFn = fetchFn;
@ -78,6 +96,11 @@ class ApiServices {
this._postApiService ??= new PostApiServiceImpl(this.fetchFn); this._postApiService ??= new PostApiServiceImpl(this.fetchFn);
return this._postApiService; return this._postApiService;
} }
get labelApiService(): LabelApiService {
this._labelApiService ??= new LabelApiServiceImpl(this.fetchFn);
return this._labelApiService;
}
} }
class Repositories { class Repositories {
@ -86,6 +109,7 @@ class Repositories {
private _authRepository?: AuthRepository; private _authRepository?: AuthRepository;
private _imageRepository?: ImageRepository; private _imageRepository?: ImageRepository;
private _postRepository?: PostRepository; private _postRepository?: PostRepository;
private _labelRepository?: LabelRepository;
constructor(apiServices: ApiServices) { constructor(apiServices: ApiServices) {
this.apiServices = apiServices; this.apiServices = apiServices;
@ -105,6 +129,11 @@ class Repositories {
this._postRepository ??= new PostRepositoryImpl(this.apiServices.postApiService); this._postRepository ??= new PostRepositoryImpl(this.apiServices.postApiService);
return this._postRepository; return this._postRepository;
} }
get labelRepository(): LabelRepository {
this._labelRepository ??= new LabelRepositoryImpl(this.apiServices.labelApiService);
return this._labelRepository;
}
} }
class UseCases { class UseCases {
@ -115,6 +144,8 @@ class UseCases {
private _getAllPostsUseCase?: GetAllPostsUseCase; private _getAllPostsUseCase?: GetAllPostsUseCase;
private _getPostUseCase?: GetPostUseCase; private _getPostUseCase?: GetPostUseCase;
private _createPostUseCase?: CreatePostUseCase; private _createPostUseCase?: CreatePostUseCase;
private _getAllLabelsUseCase?: GetAllLabelsUseCase;
private _createLabelUseCase?: CreateLabelUseCase;
constructor(repositories: Repositories) { constructor(repositories: Repositories) {
this.repositories = repositories; this.repositories = repositories;
@ -144,4 +175,14 @@ class UseCases {
this._createPostUseCase ??= new CreatePostUseCase(this.repositories.postRepository); this._createPostUseCase ??= new CreatePostUseCase(this.repositories.postRepository);
return this._createPostUseCase; return this._createPostUseCase;
} }
get getAllLabelsUseCase(): GetAllLabelsUseCase {
this._getAllLabelsUseCase ??= new GetAllLabelsUseCase(this.repositories.labelRepository);
return this._getAllLabelsUseCase;
}
get createLabelUseCase(): CreateLabelUseCase {
this._createLabelUseCase ??= new CreateLabelUseCase(this.repositories.labelRepository);
return this._createLabelUseCase;
}
} }

View File

@ -1,6 +1,6 @@
import z from 'zod'; import z from 'zod';
export const ImageInfoResponseSchema = z.object({ export const imageInfoResponseSchema = z.object({
id: z.int32(), id: z.int32(),
mime_type: z.string(), mime_type: z.string(),
}); });
@ -15,7 +15,7 @@ export class ImageInfoResponseDto {
} }
static fromJson(json: unknown): ImageInfoResponseDto { static fromJson(json: unknown): ImageInfoResponseDto {
const parsedJson = ImageInfoResponseSchema.parse(json); const parsedJson = imageInfoResponseSchema.parse(json);
return new ImageInfoResponseDto({ return new ImageInfoResponseDto({
id: parsedJson.id, id: parsedJson.id,
mimeType: parsedJson.mime_type, mimeType: parsedJson.mime_type,

View File

@ -1,14 +1,14 @@
import { Color } from '$lib/label/domain/entity/color'; import { Color } from '$lib/label/domain/entity/color';
import z from 'zod'; import z from 'zod';
export const ColorResponseSchema = z.object({ export const colorResponseSchema = z.object({
red: z.number().int().min(0).max(255), red: z.number().int().min(0).max(255),
green: z.number().int().min(0).max(255), green: z.number().int().min(0).max(255),
blue: z.number().int().min(0).max(255), blue: z.number().int().min(0).max(255),
alpha: z.number().int().min(0).max(255), alpha: z.number().int().min(0).max(255),
}); });
export class ColorResponseDto { export class ColorDto {
readonly red: number; readonly red: number;
readonly green: number; readonly green: number;
readonly blue: number; readonly blue: number;
@ -21,9 +21,13 @@ export class ColorResponseDto {
this.alpha = props.alpha; this.alpha = props.alpha;
} }
static fromJson(json: unknown): ColorResponseDto { static fromEntity(color: Color): ColorDto {
const parsedJson = ColorResponseSchema.parse(json); return new ColorDto(color);
return new ColorResponseDto({ }
static fromJson(json: unknown): ColorDto {
const parsedJson = colorResponseSchema.parse(json);
return new ColorDto({
red: parsedJson.red, red: parsedJson.red,
green: parsedJson.green, green: parsedJson.green,
blue: parsedJson.blue, blue: parsedJson.blue,
@ -39,4 +43,13 @@ export class ColorResponseDto {
alpha: this.alpha, alpha: this.alpha,
}); });
} }
toJson() {
return {
red: this.red,
green: this.green,
blue: this.blue,
alpha: this.alpha,
};
}
} }

View File

@ -0,0 +1,26 @@
import { ColorDto } from '$lib/label/adapter/gateway/colorDto';
import type { CreateLabelParams } from '$lib/label/application/gateway/labelRepository';
export class CreateLabelRequestDto {
readonly name: string;
readonly color: ColorDto;
private constructor(props: { name: string; color: ColorDto }) {
this.name = props.name;
this.color = props.color;
}
static fromParams(params: CreateLabelParams): CreateLabelRequestDto {
return new CreateLabelRequestDto({
name: params.name,
color: ColorDto.fromEntity(params.color),
});
}
toJson() {
return {
name: this.name,
color: this.color.toJson(),
};
}
}

View File

@ -0,0 +1,7 @@
import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto';
export interface LabelApiService {
getAllLabels(): Promise<LabelResponseDto[]>;
createLabel(payload: CreateLabelRequestDto): Promise<LabelResponseDto>;
}

View File

@ -0,0 +1,22 @@
import { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import type {
CreateLabelParams,
LabelRepository,
} from '$lib/label/application/gateway/labelRepository';
import type { Label } from '$lib/label/domain/entity/label';
export class LabelRepositoryImpl implements LabelRepository {
constructor(private readonly labelApiService: LabelApiService) {}
async getAllLabels(): Promise<Label[]> {
const dtos = await this.labelApiService.getAllLabels();
return dtos.map((dto) => dto.toEntity());
}
async createLabel(params: CreateLabelParams): Promise<Label> {
const requestDto = CreateLabelRequestDto.fromParams(params);
const responseDto = await this.labelApiService.createLabel(requestDto);
return responseDto.toEntity();
}
}

View File

@ -1,30 +1,30 @@
import { ColorResponseDto, ColorResponseSchema } from '$lib/label/adapter/gateway/colorResponseDto'; import { ColorDto, colorResponseSchema } from '$lib/label/adapter/gateway/colorDto';
import { Label } from '$lib/label/domain/entity/label'; import { Label } from '$lib/label/domain/entity/label';
import { z } from 'zod'; import { z } from 'zod';
export const LabelResponseSchema = z.object({ export const labelResponseSchema = z.object({
id: z.int32(), id: z.int32(),
name: z.string(), name: z.string(),
color: ColorResponseSchema, color: colorResponseSchema,
}); });
export class LabelResponseDto { export class LabelResponseDto {
readonly id: number; readonly id: number;
readonly name: string; readonly name: string;
readonly color: ColorResponseDto; readonly color: ColorDto;
private constructor(props: { id: number; name: string; color: ColorResponseDto }) { private constructor(props: { id: number; name: string; color: ColorDto }) {
this.id = props.id; this.id = props.id;
this.name = props.name; this.name = props.name;
this.color = props.color; this.color = props.color;
} }
static fromJson(json: unknown): LabelResponseDto { static fromJson(json: unknown): LabelResponseDto {
const parsedJson = LabelResponseSchema.parse(json); const parsedJson = labelResponseSchema.parse(json);
return new LabelResponseDto({ return new LabelResponseDto({
id: parsedJson.id, id: parsedJson.id,
name: parsedJson.name, name: parsedJson.name,
color: ColorResponseDto.fromJson(parsedJson.color), color: ColorDto.fromJson(parsedJson.color),
}); });
} }

View File

@ -1,4 +1,4 @@
import type { Color } from '$lib/label/domain/entity/color'; import { Color } from '$lib/label/domain/entity/color';
export class ColorViewModel { export class ColorViewModel {
readonly red: number; readonly red: number;
@ -56,6 +56,19 @@ export class ColorViewModel {
}); });
} }
static fromHex(hex: string): ColorViewModel {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
if (!result) {
throw new Error('Invalid hex color format');
}
return new ColorViewModel({
red: parseInt(result[1], 16),
green: parseInt(result[2], 16),
blue: parseInt(result[3], 16),
alpha: result[4] ? parseInt(result[4], 16) : 255,
});
}
static rehydrate(props: DehydratedColorProps): ColorViewModel { static rehydrate(props: DehydratedColorProps): ColorViewModel {
return new ColorViewModel(props); return new ColorViewModel(props);
} }
@ -99,6 +112,15 @@ export class ColorViewModel {
return { h: h * 360, s: s, l: l }; return { h: h * 360, s: s, l: l };
} }
toEntity(): Color {
return new Color({
red: this.red,
green: this.green,
blue: this.blue,
alpha: this.alpha,
});
}
lighten(amount: number): ColorViewModel { lighten(amount: number): ColorViewModel {
const hsl = this.toHsl(); const hsl = this.toHsl();
hsl.l += amount; hsl.l += amount;

View File

@ -0,0 +1,40 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import type { CreateLabelParams } from '$lib/label/application/gateway/labelRepository';
import type { CreateLabelUseCase } from '$lib/label/application/useCase/createLabelUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type LabelState = AsyncState<LabelViewModel>;
export class LabelCreatedStore implements BaseStore<LabelState, CreateLabelParams> {
private readonly state = writable<LabelState>(AsyncState.idle<LabelViewModel>(null));
constructor(private readonly createLabelUseCase: CreateLabelUseCase) {}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return (params: CreateLabelParams) => this.createLabel(params);
}
private async createLabel(params: CreateLabelParams): Promise<LabelState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: LabelState;
try {
const label = await this.createLabelUseCase.execute(params);
const labelViewModel = LabelViewModel.fromEntity(label);
result = AsyncState.success(labelViewModel);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -0,0 +1,46 @@
import { AsyncState } from '$lib/common/adapter/presenter/asyncState';
import type { BaseStore } from '$lib/common/adapter/presenter/baseStore';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import type { GetAllLabelsUseCase } from '$lib/label/application/useCase/getAllLabelsUseCase';
import { captureException } from '@sentry/sveltekit';
import { get, writable } from 'svelte/store';
type LabelListState = AsyncState<readonly LabelViewModel[]>;
export class LabelsListedStore implements BaseStore<LabelListState, void> {
private readonly state = writable<LabelListState>(AsyncState.idle([]));
constructor(
private readonly getAllLabelsUseCase: GetAllLabelsUseCase,
initialData?: readonly LabelViewModel[]
) {
if (initialData) {
this.state.set(AsyncState.idle(initialData));
}
}
get subscribe() {
return this.state.subscribe;
}
get trigger() {
return () => this.loadLabels();
}
private async loadLabels(): Promise<LabelListState> {
this.state.set(AsyncState.loading(get(this.state).data));
let result: LabelListState;
try {
const labels = await this.getAllLabelsUseCase.execute();
const labelViewModels = labels.map((label) => LabelViewModel.fromEntity(label));
result = AsyncState.success(labelViewModels);
} catch (e) {
result = AsyncState.error(e, get(this.state).data);
captureException(e);
}
this.state.set(result);
return result;
}
}

View File

@ -0,0 +1,12 @@
import type { Color } from '$lib/label/domain/entity/color';
import type { Label } from '$lib/label/domain/entity/label';
export interface LabelRepository {
getAllLabels(): Promise<Label[]>;
createLabel(params: CreateLabelParams): Promise<Label>;
}
export interface CreateLabelParams {
name: string;
color: Color;
}

View File

@ -0,0 +1,13 @@
import type {
CreateLabelParams,
LabelRepository,
} from '$lib/label/application/gateway/labelRepository';
import type { Label } from '$lib/label/domain/entity/label';
export class CreateLabelUseCase {
constructor(private readonly labelRepository: LabelRepository) {}
async execute(params: CreateLabelParams): Promise<Label> {
return this.labelRepository.createLabel(params);
}
}

View File

@ -0,0 +1,10 @@
import type { LabelRepository } from '$lib/label/application/gateway/labelRepository';
import type { Label } from '$lib/label/domain/entity/label';
export class GetAllLabelsUseCase {
constructor(private readonly labelRepository: LabelRepository) {}
execute(): Promise<Label[]> {
return this.labelRepository.getAllLabels();
}
}

View File

@ -0,0 +1,39 @@
import { HttpError } from '$lib/common/framework/web/httpError';
import { Environment } from '$lib/environment';
import type { CreateLabelRequestDto } from '$lib/label/adapter/gateway/createLabelRequestDto';
import type { LabelApiService } from '$lib/label/adapter/gateway/labelApiService';
import { LabelResponseDto } from '$lib/label/adapter/gateway/labelResponseDto';
export class LabelApiServiceImpl implements LabelApiService {
constructor(private readonly fetchFn: typeof fetch) {}
async getAllLabels(): Promise<LabelResponseDto[]> {
const url = new URL('label', Environment.API_BASE_URL);
const response = await this.fetchFn(url);
if (!response.ok) {
throw new HttpError(response.status, url);
}
const data = await response.json();
return data.map(LabelResponseDto.fromJson);
}
async createLabel(payload: CreateLabelRequestDto): Promise<LabelResponseDto> {
const url = new URL('label', Environment.API_BASE_URL);
const response = await this.fetchFn(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload.toJson()),
});
if (!response.ok) {
throw new HttpError(response.status, url);
}
const data = await response.json();
return LabelResponseDto.fromJson(data);
}
}

View File

@ -0,0 +1,122 @@
<script module lang="ts">
import z from 'zod';
const formSchema = z.object({
name: z.string().trim().nonempty(),
color: z.string().regex(/^#?[a-f\d]{6}$/i),
});
type FormParams = z.infer<typeof formSchema>;
export type CreateLabelDialogFormParams = FormParams;
</script>
<script lang="ts">
import { Button, buttonVariants } from '$lib/common/framework/components/ui/button';
import { Dialog } from '$lib/common/framework/components/ui/dialog';
import DialogContent from '$lib/common/framework/components/ui/dialog/dialog-content.svelte';
import DialogFooter from '$lib/common/framework/components/ui/dialog/dialog-footer.svelte';
import DialogHeader from '$lib/common/framework/components/ui/dialog/dialog-header.svelte';
import DialogTitle from '$lib/common/framework/components/ui/dialog/dialog-title.svelte';
import DialogTrigger from '$lib/common/framework/components/ui/dialog/dialog-trigger.svelte';
import Input from '$lib/common/framework/components/ui/input/input.svelte';
import Label from '$lib/common/framework/components/ui/label/label.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
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';
const {
disabled,
onSubmit: createLabel,
}: {
disabled: boolean;
onSubmit: (params: FormParams) => Promise<void>;
} = $props();
let open = $state(false);
let formData = $state<FormParams>({
name: '',
color: '#dddddd',
});
let formErrors = $state<Partial<Record<keyof FormParams, string>>>({});
const previewLabel = $derived(
LabelViewModel.fromEntity(
new LabelEntity({
id: -1,
name: formData.name || 'Preview',
color: ColorViewModel.fromHex(formData.color).toEntity(),
})
)
);
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
formErrors = {};
const parseResult = formSchema.safeParse(formData);
if (parseResult.error) {
const errorResult = z.treeifyError(parseResult.error).properties;
formErrors.name = errorResult?.name?.errors[0];
return;
}
await createLabel(formData);
formData = {
name: '',
color: '#dddddd',
};
open = false;
}
</script>
<Dialog bind:open>
<DialogTrigger class={buttonVariants({ variant: 'default' })}>Create</DialogTrigger>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeydown={(e) => e.preventDefault()}
>
<DialogHeader class="mb-4">
<DialogTitle>Create Label</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>
<div>
<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>
</form>
<DialogFooter class="mt-6 flex flex-row items-center">
<div class="me-auto">
<PostLabel label={previewLabel} />
</div>
<Button variant="outline" onclick={() => (open = false)} {disabled}>Cancel</Button>
<Button type="submit" form="create-label-form" {disabled}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,87 @@
<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 { ColorViewModel } from '$lib/label/adapter/presenter/colorViewModel';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const labelCreatedStore = getContext<LabelCreatedStore>(LabelCreatedStore.name);
const labelCreatedState = $derived($labelCreatedStore);
const { trigger: createLabel } = labelCreatedStore;
const labelsListedStore = getContext<LabelsListedStore>(LabelsListedStore.name);
const labelsListedState = $derived($labelsListedStore);
const { trigger: loadLabels } = labelsListedStore;
async function onCreateLabelDialogSubmit(params: CreateLabelDialogFormParams) {
const colorViewModel = ColorViewModel.fromHex(params.color);
const color = colorViewModel.toEntity();
const state = await createLabel({
name: params.name,
color: color,
});
if (state.isSuccess()) {
loadLabels();
toast.success(`Label created successfully with ID: ${state.data.id}`);
} else if (state.isError()) {
toast.error('Failed to create label', {
description: state.error.message,
});
}
}
onMount(() => loadLabels());
</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</h1>
<CreateLabelDialog
disabled={labelCreatedState.isLoading()}
onSubmit={onCreateLabelDialogSubmit}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Color</TableHead>
<TableHead>Preview</TableHead>
</TableRow>
</TableHeader>
<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="text-muted-foreground font-mono text-sm">{label.color.hex}</span>
</div>
</TableCell>
<TableCell>
<div>
<PostLabel {label} />
</div>
</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>

View File

@ -5,7 +5,7 @@
</script> </script>
<div <div
class="flex flex-row items-center gap-x-1 rounded-full px-2 py-0.5" class="flex h-fit w-fit flex-row items-center gap-x-1 rounded-full px-2 py-0.5"
style="background-color: {label.color.hex};" style="background-color: {label.color.hex};"
> >
<div class="size-2 rounded-full" style="background-color: {label.color.darken(0.2).hex};"></div> <div class="size-2 rounded-full" style="background-color: {label.color.darken(0.2).hex};"></div>

View File

@ -1,14 +1,14 @@
import { LabelResponseDto, LabelResponseSchema } from '$lib/label/adapter/gateway/labelResponseDto'; import { LabelResponseDto, labelResponseSchema } from '$lib/label/adapter/gateway/labelResponseDto';
import { PostInfo } from '$lib/post/domain/entity/postInfo'; import { PostInfo } from '$lib/post/domain/entity/postInfo';
import z from 'zod'; import z from 'zod';
export const PostInfoResponseSchema = z.object({ export const postInfoResponseSchema = z.object({
id: z.int32(), id: z.int32(),
semantic_id: z.string(), semantic_id: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
preview_image_url: z.url().nullable(), preview_image_url: z.url().nullable(),
labels: z.array(LabelResponseSchema), labels: z.array(labelResponseSchema),
published_time: z.iso.datetime({ offset: true }).nullable(), published_time: z.iso.datetime({ offset: true }).nullable(),
}); });
@ -40,7 +40,7 @@ export class PostInfoResponseDto {
} }
static fromJson(json: unknown): PostInfoResponseDto { static fromJson(json: unknown): PostInfoResponseDto {
const parsedJson = PostInfoResponseSchema.parse(json); const parsedJson = postInfoResponseSchema.parse(json);
let published_time: Date | null = null; let published_time: Date | null = null;
if (parsedJson.published_time !== null) { if (parsedJson.published_time !== null) {

View File

@ -1,13 +1,13 @@
import { import {
PostInfoResponseDto, PostInfoResponseDto,
PostInfoResponseSchema, postInfoResponseSchema,
} from '$lib/post/adapter/gateway/postInfoResponseDto'; } from '$lib/post/adapter/gateway/postInfoResponseDto';
import { Post } from '$lib/post/domain/entity/post'; import { Post } from '$lib/post/domain/entity/post';
import z from 'zod'; import z from 'zod';
export const PostResponseSchema = z.object({ export const postResponseSchema = z.object({
id: z.int32(), id: z.int32(),
info: PostInfoResponseSchema, info: postInfoResponseSchema,
content: z.string(), content: z.string(),
}); });
@ -23,7 +23,7 @@ export class PostResponseDto {
} }
static fromJson(json: unknown): PostResponseDto { static fromJson(json: unknown): PostResponseDto {
const parsedJson = PostResponseSchema.parse(json); const parsedJson = postResponseSchema.parse(json);
return new PostResponseDto({ return new PostResponseDto({
id: parsedJson.id, id: parsedJson.id,
info: PostInfoResponseDto.fromJson(parsedJson.info), info: PostInfoResponseDto.fromJson(parsedJson.info),

View File

@ -11,14 +11,7 @@ type PostState = AsyncState<PostViewModel>;
export class PostCreatedStore implements BaseStore<PostState, CreatePostParams> { export class PostCreatedStore implements BaseStore<PostState, CreatePostParams> {
private readonly state = writable<PostState>(AsyncState.idle<PostViewModel>(null)); private readonly state = writable<PostState>(AsyncState.idle<PostViewModel>(null));
constructor( constructor(private readonly createPostUseCase: CreatePostUseCase) {}
private readonly createPostUseCase: CreatePostUseCase,
initialData?: PostViewModel
) {
if (initialData) {
this.state.set(AsyncState.idle(initialData));
}
}
get subscribe() { get subscribe() {
return this.state.subscribe; return this.state.subscribe;

View File

@ -7,7 +7,7 @@
.max(100) .max(100)
.regex(/\D/) .regex(/\D/)
.regex(/^[a-zA-Z0-9_-]+$/), .regex(/^[a-zA-Z0-9_-]+$/),
title: z.string().trim().nonempty().max(100), title: z.string().trim().nonempty(),
}); });
type FormParams = z.infer<typeof formSchema>; type FormParams = z.infer<typeof formSchema>;
@ -45,12 +45,8 @@
const parseResult = formSchema.safeParse(formData); const parseResult = formSchema.safeParse(formData);
if (parseResult.error) { if (parseResult.error) {
const errorResult = z.treeifyError(parseResult.error).properties; const errorResult = z.treeifyError(parseResult.error).properties;
if (errorResult?.semanticId?.errors) { formErrors.semanticId = errorResult?.semanticId?.errors[0];
formErrors.semanticId = errorResult.semanticId.errors[0]; formErrors.title = errorResult?.title?.errors[0];
}
if (errorResult?.title?.errors) {
formErrors.title = errorResult.title.errors[0];
}
return; return;
} }

View File

@ -0,0 +1,13 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { container } = locals;
const store = container.createLabelsListedStore();
const { trigger: loadLabels } = store;
const state = await loadLabels();
return {
dehydratedData: state.data?.map((label) => label.dehydrate()),
};
};

View File

@ -1 +1,21 @@
<div>Label</div> <script lang="ts">
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 { getContext, setContext } from 'svelte';
import type { PageProps } from './$types';
import { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
const { data }: PageProps = $props();
const container = getContext<Container>(Container.name);
const labelCreatedStore = container.createLabelCreatedStore();
setContext(LabelCreatedStore.name, labelCreatedStore);
const initialData = data.dehydratedData?.map((label) => LabelViewModel.rehydrate(label));
const labelsListedStore = container.createLabelsListedStore(initialData);
setContext(LabelsListedStore.name, labelsListedStore);
</script>
<LabelManagementPage />