BLOG-136 Dashboard route and frontend authentication (#137)
All checks were successful
Frontend CI / build (push) Successful in 1m23s

### Description

- Create a dashboard layout (with a navigation sidebar)
- Only logged in users are able to access (others 404)

### Package Changes

> shadcn-svelte

```json
{
  "@lucide/svelte": "^0.545.0",
  "clsx": "^2.1.1",
  "tailwind-merge": "^3.3.1",
  "tailwind-variants": "^3.1.1",
  "tw-animate-css": "^1.4.0"
}
```

### Screenshots

|Status|Screenshot|
|-|-|
|Logged in|![image.png](/attachments/5d5fc3b9-c15f-43b8-8201-dedeef3c3fb9)|
|Logged out|![image.png](/attachments/29683c9f-395c-4dd8-892f-dc21df2dd0cc)|

### Reference

Resolves #136.

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #137
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-10-13 20:33:36 +08:00 committed by squid
parent 1ae104cd56
commit e8c5e678d5
40 changed files with 711 additions and 34 deletions

View File

@ -1 +1 @@
PUBLIC_API_BASE_URL=http://127.0.0.1:5173/api/
PUBLIC_API_BASE_URL=http://localhost:5173/api/

16
frontend/components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "gray"
},
"aliases": {
"components": "$lib/common/framework/components",
"utils": "$lib/common/framework/components/utils",
"ui": "$lib/common/framework/components/ui",
"hooks": "$lib/common/framework/components/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

View File

@ -13,10 +13,14 @@
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"dependencies": {
"@sentry/sveltekit": "^10.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@fortawesome/fontawesome-free": "^7.0.0",
"@lucide/svelte": "^0.545.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.0",
@ -25,6 +29,7 @@
"@tailwindcss/vite": "^4.0.0",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0",
"clsx": "^2.1.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@ -36,7 +41,10 @@
"sanitize-html": "^2.17.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
@ -47,8 +55,5 @@
"esbuild"
]
},
"packageManager": "pnpm@10.12.4",
"dependencies": {
"@sentry/sveltekit": "^10.1.0"
}
"packageManager": "pnpm@10.17.1"
}

View File

@ -21,6 +21,9 @@ importers:
'@fortawesome/fontawesome-free':
specifier: ^7.0.0
version: 7.0.0
'@lucide/svelte':
specifier: ^0.545.0
version: 0.545.0(svelte@5.36.13)
'@sveltejs/adapter-auto':
specifier: ^6.0.0
version: 6.0.1(@sveltejs/kit@2.25.1(@sveltejs/vite-plugin-svelte@6.1.0(svelte@5.36.13)(vite@7.0.5(@types/node@24.2.0)(jiti@2.4.2)(lightningcss@1.30.1)))(svelte@5.36.13)(vite@7.0.5(@types/node@24.2.0)(jiti@2.4.2)(lightningcss@1.30.1)))
@ -45,6 +48,9 @@ importers:
'@types/sanitize-html':
specifier: ^2.16.0
version: 2.16.0
clsx:
specifier: ^2.1.1
version: 2.1.1
eslint:
specifier: ^9.18.0
version: 9.31.0(jiti@2.4.2)
@ -78,9 +84,18 @@ importers:
svelte-check:
specifier: ^4.0.0
version: 4.3.0(picomatch@4.0.3)(svelte@5.36.13)(typescript@5.8.3)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
tailwind-variants:
specifier: ^3.1.1
version: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.11)
tailwindcss:
specifier: ^4.0.0
version: 4.1.11
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.0.0
version: 5.8.3
@ -416,6 +431,11 @@ packages:
'@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@lucide/svelte@0.545.0':
resolution: {integrity: sha512-RlAtWefx9MdpXaOMbx3Qv3/NqpeZKOIPxN2D0RBN2+op0opKly8VgYEEWZTT6Ow/zf7UwyTg6/0ExJlsVLK+8g==}
peerDependencies:
svelte: ^5
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -2118,6 +2138,19 @@ packages:
resolution: {integrity: sha512-LnSywHHQM/nJekC65d84T1Yo85IeCYN4AryWYPhTokSvcEAFdYFCfbMhX1mc0zHizT736QQj0nalUk+SXaWrEQ==}
engines: {node: '>=18'}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwind-variants@3.1.1:
resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==}
engines: {node: '>=16.x', pnpm: '>=7.x'}
peerDependencies:
tailwind-merge: '>=3.0.0'
tailwindcss: '*'
peerDependenciesMeta:
tailwind-merge:
optional: true
tailwindcss@4.1.11:
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
@ -2159,6 +2192,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -2561,6 +2597,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4
'@lucide/svelte@0.545.0(svelte@5.36.13)':
dependencies:
svelte: 5.36.13
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -4284,6 +4324,14 @@ snapshots:
magic-string: 0.30.17
zimmerframe: 1.1.2
tailwind-merge@3.3.1: {}
tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.11):
dependencies:
tailwindcss: 4.1.11
optionalDependencies:
tailwind-merge: 3.3.1
tailwindcss@4.1.11: {}
tapable@2.2.2: {}
@ -4323,6 +4371,8 @@ snapshots:
tslib@2.8.1: {}
tw-animate-css@1.4.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View File

@ -1,7 +1,126 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin '@tailwindcss/typography';
@config "../tailwind.config.js";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.13 0.028 261.692);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692);
--primary: oklch(0.21 0.034 264.665);
--primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665);
--muted: oklch(0.967 0.003 264.542);
--muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.928 0.006 264.531);
--input: oklch(0.928 0.006 264.531);
--ring: oklch(0.707 0.022 261.325);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.002 247.839);
--sidebar-foreground: oklch(0.13 0.028 261.692);
--sidebar-primary: oklch(0.21 0.034 264.665);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.967 0.003 264.542);
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
--sidebar-border: oklch(0.928 0.006 264.531);
--sidebar-ring: oklch(0.707 0.022 261.325);
}
.dark {
--background: oklch(0.13 0.028 261.692);
--foreground: oklch(0.985 0.002 247.839);
--card: oklch(0.21 0.034 264.665);
--card-foreground: oklch(0.985 0.002 247.839);
--popover: oklch(0.21 0.034 264.665);
--popover-foreground: oklch(0.985 0.002 247.839);
--primary: oklch(0.928 0.006 264.531);
--primary-foreground: oklch(0.21 0.034 264.665);
--secondary: oklch(0.278 0.033 256.848);
--secondary-foreground: oklch(0.985 0.002 247.839);
--muted: oklch(0.278 0.033 256.848);
--muted-foreground: oklch(0.707 0.022 261.325);
--accent: oklch(0.278 0.033 256.848);
--accent-foreground: oklch(0.985 0.002 247.839);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.034 264.665);
--sidebar-foreground: oklch(0.985 0.002 247.839);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
--sidebar-accent: oklch(0.278 0.033 256.848);
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@font-face {
font-family: 'HackNerdMono';
src: url('/font/HackNerdMono.woff2') format('woff2');
@ -30,6 +149,10 @@ body {
@apply bg-white font-sans text-base font-normal text-gray-700;
}
button {
@apply cursor-pointer;
}
.container {
@apply mx-auto max-w-screen-xl px-4 md:px-6;
}

View File

@ -5,6 +5,7 @@ declare global {
// interface Error {}
interface Locals {
authBloc: import('$lib/auth/adapter/presenter/authBloc').AuthBloc;
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
}

View File

@ -8,6 +8,14 @@ import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import type { Handle } from '@sveltejs/kit';
import { Environment } from '$lib/environment';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import { AuthBloc } from '$lib/auth/adapter/presenter/authBloc';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
Sentry.init({
dsn: Environment.SENTRY_DSN,
@ -16,11 +24,16 @@ Sentry.init({
});
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {
const postApiService = new PostApiServiceImpl(event.fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const authApiService: AuthApiService = new AuthApiServiceImpl(event.fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getCurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const postApiService: PostApiService = new PostApiServiceImpl(event.fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const getPostUseCase = new GetPostUseCase(postRepository);
event.locals.authBloc = new AuthBloc(getCurrentUserUseCase);
event.locals.postListBloc = new PostListBloc(getAllPostsUseCase);
event.locals.postBloc = new PostBloc(getPostUseCase);

View File

@ -0,0 +1,5 @@
import type { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto';
export interface AuthApiService {
getCurrentUser(): Promise<UserResponseDto | null>;
}

View File

@ -0,0 +1,12 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { User } from '$lib/auth/domain/entity/user';
export class AuthRepositoryImpl implements AuthRepository {
constructor(private readonly authApiService: AuthApiService) {}
async getCurrentUser(): Promise<User | null> {
const result = await this.authApiService.getCurrentUser();
return result?.toEntity() ?? null;
}
}

View File

@ -0,0 +1,37 @@
import { User } from '$lib/auth/domain/entity/user';
import z from 'zod';
export const UserResponseSchema = z.object({
id: z.int32(),
displayed_name: z.string(),
email: z.email(),
});
export class UserResponseDto {
readonly id: number;
readonly displayedName: string;
readonly email: string;
private constructor(props: { id: number; displayedName: string; email: string }) {
this.id = props.id;
this.displayedName = props.displayedName;
this.email = props.email;
}
static fromJson(json: unknown): UserResponseDto {
const parsedJson = UserResponseSchema.parse(json);
return new UserResponseDto({
id: parsedJson.id,
displayedName: parsedJson.displayed_name,
email: parsedJson.email,
});
}
toEntity(): User {
return new User({
id: this.id,
name: this.displayedName,
email: this.email,
});
}
}

View File

@ -0,0 +1,59 @@
import { AuthViewModel } from '$lib/auth/adapter/presenter/authViewModel';
import { UserViewModel } from '$lib/auth/adapter/presenter/userViewModel';
import type { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { get, writable } from 'svelte/store';
export type AuthState = AsyncState<AuthViewModel>;
export type AuthEvent = CurrentUserLoadedEvent;
export class AuthBloc {
private readonly state = writable<AuthState>({
status: StatusType.Idle,
});
constructor(
private readonly getCurrentUserUseCase: GetCurrentUserUseCase,
initialData?: AuthViewModel
) {
this.state.set({
status: StatusType.Idle,
data: initialData,
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: AuthEvent): Promise<AuthState> {
switch (event.event) {
case AuthEventType.CurrentUserLoadedEvent:
return this.loadCurrentUser();
}
}
private async loadCurrentUser(): Promise<AuthState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const user = await this.getCurrentUserUseCase.execute();
const userViewModel = user ? UserViewModel.fromEntity(user) : null;
const authViewModel = AuthViewModel.fromEntity(userViewModel);
const result: AuthState = {
status: StatusType.Success,
data: authViewModel,
};
this.state.set(result);
return result;
}
}
export enum AuthEventType {
CurrentUserLoadedEvent,
}
interface CurrentUserLoadedEvent {
event: AuthEventType.CurrentUserLoadedEvent;
}

View File

@ -0,0 +1,33 @@
import { UserViewModel, type DehydratedUserProps } from '$lib/auth/adapter/presenter/userViewModel';
export class AuthViewModel {
readonly user: UserViewModel | null;
constructor(params: { user: UserViewModel | null }) {
this.user = params.user;
}
static fromEntity(user: UserViewModel | null): AuthViewModel {
return new AuthViewModel({ user });
}
static rehydrate(props: DehydratedAuthProps): AuthViewModel {
return new AuthViewModel({
user: props.user ? UserViewModel.rehydrate(props.user) : null,
});
}
dehydrate(): DehydratedAuthProps {
return {
user: this.user ? this.user.dehydrate() : null,
};
}
get isAuthenticated(): boolean {
return this.user !== null;
}
}
export interface DehydratedAuthProps {
user: DehydratedUserProps | null;
}

View File

@ -0,0 +1,43 @@
import type { User } from '$lib/auth/domain/entity/user';
export class UserViewModel {
readonly id: number;
readonly name: string;
readonly email: string;
private constructor(props: { id: number; name: string; email: string }) {
this.id = props.id;
this.name = props.name;
this.email = props.email;
}
static fromEntity(user: User): UserViewModel {
return new UserViewModel({
id: user.id,
name: user.name,
email: user.email,
});
}
static rehydrate(props: DehydratedUserProps): UserViewModel {
return new UserViewModel({
id: props.id,
name: props.name,
email: props.email,
});
}
dehydrate(): DehydratedUserProps {
return {
id: this.id,
name: this.name,
email: this.email,
};
}
}
export interface DehydratedUserProps {
id: number;
name: string;
email: string;
}

View File

@ -0,0 +1,5 @@
import type { User } from '$lib/auth/domain/entity/user';
export interface AuthRepository {
getCurrentUser(): Promise<User | null>;
}

View File

@ -0,0 +1,10 @@
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { User } from '$lib/auth/domain/entity/user';
export class GetCurrentUserUseCase {
constructor(private readonly authRepository: AuthRepository) {}
async execute(): Promise<User | null> {
return await this.authRepository.getCurrentUser();
}
}

View File

@ -0,0 +1,11 @@
export class User {
id: number;
name: string;
email: string;
constructor(props: { id: number; name: string; email: string }) {
this.id = props.id;
this.name = props.name;
this.email = props.email;
}
}

View File

@ -0,0 +1,20 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto';
import { Environment } from '$lib/environment';
export class AuthApiServiceImpl implements AuthApiService {
constructor(private readonly fetchFn: typeof fetch) {}
async getCurrentUser(): Promise<UserResponseDto | null> {
const url = new URL('me', Environment.API_BASE_URL);
const response = await this.fetchFn(url);
if (!response.ok) {
return null;
}
const json = await response.json();
return UserResponseDto.fromJson(json);
}
}

View File

@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/common/framework/components/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@ -0,0 +1,13 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View File

@ -0,0 +1,14 @@
<div
class="mx-auto flex min-h-content-height max-w-screen-xl flex-col items-center justify-center px-4 md:px-6"
>
<div class="flex flex-row items-end gap-x-4 md:gap-x-6">
<h1 class="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1>
<h2 class="flex flex-row items-center gap-x-2 text-2xl font-bold md:gap-x-2.5 md:text-3xl">
<div class="h-7 w-1.5 bg-gray-800 md:h-9 md:w-2"></div>
<div class="rounded-md bg-blue-600 px-1 py-px md:px-1.5 md:py-0.5">
<span class="text-white">Not</span>
</div>
<span class="text-gray-800">Found.</span>
</h2>
</div>
</div>

View File

@ -3,7 +3,7 @@
import NavbarAction from '$lib/common/framework/ui/NavbarAction.svelte';
</script>
<div class="border-b border-gray-300">
<nav class="border-b border-gray-300">
<div
class="mx-auto flex h-toolbar-height max-w-screen-xl flex-row items-center justify-between px-4 md:px-6"
>
@ -20,4 +20,4 @@
/>
</div>
</div>
</div>
</nav>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '$lib/common/framework/components/ui/button/index';
import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink';
const { link }: { link: DashboardLink } = $props();
const isSelected = $derived.by(() => {
const pathname = page.url.pathname;
return pathname === link.href || pathname.startsWith(link.href + '/');
});
</script>
<a href={link.href}>
<Button variant={isSelected ? 'outline' : 'ghost'} class="w-full">
<span class="w-full text-left">
{link.label}
</span>
</Button>
</a>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink';
import DashboardLinkButton from '$lib/dashboard/framework/ui/DashboardLinkButton.svelte';
const { links }: { links: DashboardLink[] } = $props();
</script>
<div class="w-3xs border-e border-true-gray-300 p-8">
<div class="mb-3 font-bold">Dashboard</div>
<div class="flex flex-col">
{#each links as link (link.href)}
<DashboardLinkButton {link} />
{/each}
</div>
</div>

View File

@ -0,0 +1,4 @@
export interface DashboardLink {
label: string;
href: string;
}

View File

@ -1,5 +1,5 @@
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';

View File

@ -1,4 +1,4 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostsUseCase {

View File

@ -1,4 +1,4 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
export class GetPostUseCase {

View File

@ -4,12 +4,12 @@ import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseD
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService {
constructor(private fetchFn: typeof fetch) {}
constructor(private readonly fetchFn: typeof fetch) {}
async getAllPosts(): Promise<PostInfoResponseDto[]> {
const url = new URL('post', Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
const response = await this.fetchFn(url);
if (!response.ok) {
return [];
@ -22,7 +22,7 @@ export class PostApiServiceImpl implements PostApiService {
async getPost(id: string): Promise<PostResponseDto | null> {
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
const response = await this.fetchFn(url);
if (!response.ok) {
return null;

View File

@ -1,14 +1,5 @@
<div
class="mx-auto flex min-h-content-height max-w-screen-xl flex-col items-center justify-center px-4 md:px-6"
>
<div class="flex flex-row items-end gap-x-4 md:gap-x-6">
<h1 class="text-5xl font-extrabold text-gray-800 underline md:text-7xl">404</h1>
<h2 class="flex flex-row items-center gap-x-2 text-2xl font-bold md:gap-x-2.5 md:text-3xl">
<div class="h-7 w-1.5 bg-gray-800 md:h-9 md:w-2"></div>
<div class="rounded-md bg-blue-600 px-1 py-px md:px-1.5 md:py-0.5">
<span class="text-white">Not</span>
</div>
<span class="text-gray-800">Found.</span>
</h2>
</div>
</div>
<script>
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
</script>
<ErrorPage />

View File

@ -12,6 +12,8 @@
</svelte:head>
<div class="min-h-screen">
<Navbar />
<main>
<slot />
</main>
</div>
<Footer />

View File

@ -0,0 +1,56 @@
<script lang="ts">
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import { AuthBloc, AuthEventType } from '$lib/auth/adapter/presenter/authBloc';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import { onMount, setContext } from 'svelte';
import type { LayoutProps } from './$types';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
import ErrorPage from '$lib/common/framework/ui/ErrorPage.svelte';
import DashboardNavbar from '$lib/dashboard/framework/ui/DashboardNavbar.svelte';
import type { DashboardLink } from '$lib/dashboard/framework/ui/dashboardLink';
const { children }: LayoutProps = $props();
const authApiService: AuthApiService = new AuthApiServiceImpl(fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getcurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const authBloc = new AuthBloc(getcurrentUserUseCase);
setContext(AuthBloc.name, authBloc);
onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent }));
const authState = $derived($authBloc);
const isLoading = $derived.by(
() => authState.status === StatusType.Loading || authState.status === StatusType.Idle
);
const hasError = $derived.by(() => {
if (authState.status === StatusType.Error) {
return true;
}
if (authState.status === StatusType.Success && !authState.data.isAuthenticated) {
return true;
}
return false;
});
const links: DashboardLink[] = [
{ label: 'Post', href: '/dashboard/post' },
{ label: 'Label', href: '/dashboard/label' },
{ label: 'Image', href: '/dashboard/image' },
];
</script>
{#if isLoading}
<div></div>
{:else if hasError}
<ErrorPage />
{:else}
<div class="grid min-h-content-height grid-cols-[auto_1fr]">
<DashboardNavbar {links} />
{@render children()}
</div>
{/if}

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
onMount(() => {
if (page.url.pathname === '/dashboard') {
goto('/dashboard/post');
}
});
</script>

View File

@ -0,0 +1 @@
<div>Image</div>

View File

@ -0,0 +1 @@
<div>Label</div>

View File

@ -0,0 +1 @@
<div>Post</div>

View File

@ -7,13 +7,15 @@
import type { PageProps } from './$types';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
let { data }: PageProps = $props();
const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post));
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const postApiService: PostApiService = new PostApiServiceImpl(fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);

View File

@ -7,14 +7,16 @@
import { setContext } from 'svelte';
import type { PageProps } from './$types';
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
const { data, params }: PageProps = $props();
const { id } = params;
const initialData = PostViewModel.rehydrate(data.dehydratedData!);
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const postApiService: PostApiService = new PostApiServiceImpl(fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getPostUseCase = new GetPostUseCase(postRepository);
const postBloc = new PostBloc(getPostUseCase, initialData);

View File

@ -1,2 +1,4 @@
User-agent: *
Allow: /
Disallow: /dashboard/
Disallow: /api/