super-svelte-skeleton 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/.github/workflows/ninja_i18n.yml +23 -0
- package/.prettierignore +9 -0
- package/.prettierrc +12 -0
- package/.vscode/extensions.json +5 -0
- package/.vscode/launch.json +15 -0
- package/.vscode/settings.json +30 -0
- package/README.md +237 -0
- package/_gitignore +27 -0
- package/eslint.config.js +40 -0
- package/messages/ar.json +70 -0
- package/messages/en.json +70 -0
- package/messages/es.json +70 -0
- package/package.json +54 -0
- package/project.inlang/settings.json +15 -0
- package/src/app.css +8 -0
- package/src/app.d.ts +20 -0
- package/src/app.html +40 -0
- package/src/auto-imports.d.ts +35 -0
- package/src/hooks.client.ts +17 -0
- package/src/hooks.server.ts +73 -0
- package/src/hooks.ts +15 -0
- package/src/lib/entities/auth/api/endpoints.ts +11 -0
- package/src/lib/entities/auth/api/service.ts +35 -0
- package/src/lib/entities/auth/index.ts +9 -0
- package/src/lib/entities/auth/store.svelte.ts +50 -0
- package/src/lib/entities/auth/types.ts +33 -0
- package/src/lib/entities/user/api/endpoints.ts +6 -0
- package/src/lib/entities/user/api/service.ts +10 -0
- package/src/lib/entities/user/index.ts +2 -0
- package/src/lib/entities/user/types.ts +18 -0
- package/src/lib/features/theme-editor/constants.ts +33 -0
- package/src/lib/features/theme-editor/index.ts +3 -0
- package/src/lib/features/theme-editor/types.ts +10 -0
- package/src/lib/features/theme-editor/ui/CSSOutput.svelte +17 -0
- package/src/lib/features/theme-editor/ui/ColorCard.svelte +66 -0
- package/src/lib/features/theme-editor/ui/ThemeEditorWidget.svelte +319 -0
- package/src/lib/features/theme-editor/ui/ThemePreview.svelte +121 -0
- package/src/lib/features/theme-editor/ui/TypographySettings.svelte +73 -0
- package/src/lib/features/theme-editor/utils.ts +10 -0
- package/src/lib/shared/api/client.ts +47 -0
- package/src/lib/shared/api/index.ts +3 -0
- package/src/lib/shared/api/types.ts +25 -0
- package/src/lib/shared/config/api.ts +1 -0
- package/src/lib/shared/config/index.ts +2 -0
- package/src/lib/shared/config/routes.ts +18 -0
- package/src/lib/shared/i18n/index.ts +1 -0
- package/src/lib/shared/index.ts +2 -0
- package/src/lib/tailwind.config.ts +28 -0
- package/src/lib/widgets/topbar/Topbar.svelte +122 -0
- package/src/lib/widgets/topbar/constants.ts +16 -0
- package/src/lib/widgets/topbar/index.ts +2 -0
- package/src/params/integer.ts +5 -0
- package/src/routes/(app)/(admin)/+layout.server.ts +14 -0
- package/src/routes/(app)/(admin)/admin/+page.svelte +101 -0
- package/src/routes/(app)/+layout.server.ts +9 -0
- package/src/routes/(app)/+layout.svelte +12 -0
- package/src/routes/(app)/settings/+page.svelte +48 -0
- package/src/routes/(app)/theme/+page.svelte +5 -0
- package/src/routes/(auth)/forgot-password/+page.svelte +83 -0
- package/src/routes/(auth)/login/+page.server.ts +66 -0
- package/src/routes/(auth)/login/+page.svelte +156 -0
- package/src/routes/(auth)/logout/+page.server.ts +16 -0
- package/src/routes/(auth)/register/+page.svelte +167 -0
- package/src/routes/(auth)/reset-password/+page.svelte +127 -0
- package/src/routes/+error.svelte +95 -0
- package/src/routes/+layout.svelte +36 -0
- package/src/routes/+layout.ts +24 -0
- package/src/routes/+page.svelte +192 -0
- package/src/routes/+page.ts +3 -0
- package/static/config/config.local.json +3 -0
- package/static/config/config.prod.json +3 -0
- package/static/favicon.svg +1 -0
- package/static/logo.svg +7 -0
- package/static/profile.avif +0 -0
- package/static/smile.jpg +0 -0
- package/static/styles/theme-dark.css +30 -0
- package/static/styles/theme-light.css +28 -0
- package/stats.html +4950 -0
- package/svelte.config.js +78 -0
- package/tsconfig.json +46 -0
- package/vite.config.ts +51 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { asset, resolve } from '$app/paths';
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { browser } from '$app/environment';
|
|
5
|
+
import { onMount } from 'svelte';
|
|
6
|
+
import * as m from '$paraglide/messages';
|
|
7
|
+
import { MENU_ITEMS } from './constants';
|
|
8
|
+
import { LOGOUT_ROUTE, PROFILE_ROUTE } from '$shared/config';
|
|
9
|
+
import {
|
|
10
|
+
Search,
|
|
11
|
+
CaretDownFill,
|
|
12
|
+
Person,
|
|
13
|
+
BoxArrowRight,
|
|
14
|
+
Sun,
|
|
15
|
+
Moon,
|
|
16
|
+
} from 'svelte-bootstrap-icons';
|
|
17
|
+
import Globe2 from 'svelte-bootstrap-icons/lib/Globe2.svelte';
|
|
18
|
+
import { page } from '$app/state';
|
|
19
|
+
import { getLocale, setLocale, locales } from '$paraglide/runtime';
|
|
20
|
+
import { ELocale, EStorageKey, ETheme } from '@aryagg/types';
|
|
21
|
+
import { getItem, setItem, setTheme } from '@aryagg/utils';
|
|
22
|
+
import { Avatar, DropdownMenu } from '@aryagg/ui-kit';
|
|
23
|
+
|
|
24
|
+
const profileItems = [
|
|
25
|
+
{ label: 'Profile', icon: Person, onclick: () => goto(resolve(PROFILE_ROUTE, {})) },
|
|
26
|
+
{ label: 'Logout', icon: BoxArrowRight, danger: true, divider: true, onclick: () => goto(resolve(LOGOUT_ROUTE, {})) },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
let currentLocale: ELocale = $state(ELocale.EN);
|
|
30
|
+
let activeTheme: ETheme = $state((getItem(EStorageKey.THEME) as ETheme) || ETheme.LIGHT);
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
if (browser) {
|
|
34
|
+
const savedLocale = getItem(EStorageKey.LANGUAGE) as ELocale | null;
|
|
35
|
+
currentLocale = (savedLocale ?? getLocale()) as ELocale;
|
|
36
|
+
if (savedLocale) setLocale(savedLocale);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const handleLanguageChange = (locale: ELocale) => {
|
|
41
|
+
currentLocale = locale;
|
|
42
|
+
setLocale(locale);
|
|
43
|
+
setItem(EStorageKey.LANGUAGE, locale);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let languageItems = $derived(
|
|
47
|
+
locales.map((locale) => ({
|
|
48
|
+
label: locale.toUpperCase(),
|
|
49
|
+
onclick: () => handleLanguageChange(locale as ELocale),
|
|
50
|
+
})),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function handleThemeChange() {
|
|
54
|
+
activeTheme = activeTheme === ETheme.DARK ? ETheme.LIGHT : ETheme.DARK;
|
|
55
|
+
setTheme(activeTheme, EStorageKey.THEME);
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<header class="h-14 bg-transparent px-2 shadow-2xs backdrop-blur-sm">
|
|
60
|
+
<div class="container mx-auto flex items-center justify-between">
|
|
61
|
+
<div class="flex flex-none items-center gap-3 py-2">
|
|
62
|
+
<a href={resolve('/', {})} class="flex aspect-square size-10 items-center justify-center rounded-md transition-colors" aria-label="Home" title="Home">
|
|
63
|
+
<img alt="Logo" class="size-full object-contain" src={asset('/logo.svg')} />
|
|
64
|
+
</a>
|
|
65
|
+
<h5 class="font-bold whitespace-nowrap">{m.app_name()}</h5>
|
|
66
|
+
|
|
67
|
+
<div class="relative min-w-45 sm:min-w-60 md:min-w-75">
|
|
68
|
+
<Search class="text-tertiary/70 absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
|
69
|
+
<input type="search" placeholder="Search..." class="text-secondary/80 rounded-2xl py-2 pl-8" />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="flex items-center gap-2">
|
|
74
|
+
<nav class="flex items-end justify-center">
|
|
75
|
+
{#each MENU_ITEMS as menu (menu.id)}
|
|
76
|
+
{@const isActive = page.url.pathname === resolve(menu.href, {})}
|
|
77
|
+
<a
|
|
78
|
+
href={resolve(menu.href, {})}
|
|
79
|
+
class="group hover:text-accent/80! relative flex flex-col items-center justify-between pb-1 text-[11px]! font-medium transition-colors {isActive ? 'text-accent' : 'text-secondary'}"
|
|
80
|
+
>
|
|
81
|
+
{#if isActive}
|
|
82
|
+
<menu.selectedIcon class="size-5" />
|
|
83
|
+
{:else}
|
|
84
|
+
<menu.icon class="size-5" />
|
|
85
|
+
{/if}
|
|
86
|
+
<span class="hidden px-4 pt-1 sm:block">{menu.label}</span>
|
|
87
|
+
<span class="absolute -bottom-1 h-0.5 w-full rounded-full transition-opacity {isActive ? 'bg-accent opacity-100' : 'bg-transparent opacity-0'}"></span>
|
|
88
|
+
</a>
|
|
89
|
+
{/each}
|
|
90
|
+
</nav>
|
|
91
|
+
|
|
92
|
+
<DropdownMenu items={languageItems} align="right">
|
|
93
|
+
{#snippet trigger({ open, toggle })}
|
|
94
|
+
<button onclick={toggle} aria-label="Switch language" aria-expanded={open} class="btn-ghost text-secondary hover:text-primary flex flex-col items-center gap-1 border-0 px-2 text-[11px] font-semibold">
|
|
95
|
+
<Globe2 class="size-5" />
|
|
96
|
+
<div class="hidden uppercase sm:block">{currentLocale}</div>
|
|
97
|
+
</button>
|
|
98
|
+
{/snippet}
|
|
99
|
+
</DropdownMenu>
|
|
100
|
+
|
|
101
|
+
<button onclick={handleThemeChange} aria-label="Toggle theme" class="btn-ghost text-secondary hover:text-primary flex flex-col items-center gap-1 border-0 px-4 py-1 text-[11px] font-semibold">
|
|
102
|
+
{#if activeTheme === ETheme.LIGHT}
|
|
103
|
+
<Sun class="size-5" /><span class="hidden sm:block">Light</span>
|
|
104
|
+
{:else}
|
|
105
|
+
<Moon class="size-5" /><span class="hidden sm:block">Dark</span>
|
|
106
|
+
{/if}
|
|
107
|
+
</button>
|
|
108
|
+
|
|
109
|
+
<DropdownMenu items={profileItems} align="right">
|
|
110
|
+
{#snippet trigger({ open, toggle })}
|
|
111
|
+
<button onclick={toggle} aria-label="User menu" aria-expanded={open} class="group text-secondary hover:text-accent/80! flex flex-col items-center gap-1 border-0 px-4 pt-0 pb-1 text-[11px] font-medium transition-colors">
|
|
112
|
+
<Avatar src={asset('/profile.avif')} name="Romeo & Juliet" size="xs" />
|
|
113
|
+
<div class="flex items-center gap-1">
|
|
114
|
+
<span class="hidden sm:block">Me</span>
|
|
115
|
+
<CaretDownFill class="mt-0.5 size-3 transition-transform {open ? 'rotate-180' : ''}" />
|
|
116
|
+
</div>
|
|
117
|
+
</button>
|
|
118
|
+
{/snippet}
|
|
119
|
+
</DropdownMenu>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</header>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { INavItem } from '@aryagg/types';
|
|
2
|
+
import {
|
|
3
|
+
Grid3x3Gap, Grid3x3GapFill,
|
|
4
|
+
Gear, GearFill,
|
|
5
|
+
Grid, ListUl,
|
|
6
|
+
} from 'svelte-bootstrap-icons';
|
|
7
|
+
|
|
8
|
+
export const MENU_ITEMS: INavItem[] = [
|
|
9
|
+
{ label: 'Dashboard', id: 'dashboard', href: '/', icon: Grid3x3Gap, selectedIcon: Grid3x3GapFill },
|
|
10
|
+
{ label: 'Theme', id: 'theme', href: '/theme', icon: Gear, selectedIcon: GearFill },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const VIEW_TABS = [
|
|
14
|
+
{ id: 'list', label: '', icon: ListUl },
|
|
15
|
+
{ id: 'grid', label: '', icon: Grid },
|
|
16
|
+
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { redirect } from '@sveltejs/kit';
|
|
2
|
+
import { LOGIN_ROUTE, DASHBOARD_ROUTE } from '$shared/config';
|
|
3
|
+
import { EUserRole } from '@aryagg/types';
|
|
4
|
+
|
|
5
|
+
export async function load({ locals }) {
|
|
6
|
+
if (!locals.isAuthenticated || !locals.user) {
|
|
7
|
+
throw redirect(303, LOGIN_ROUTE);
|
|
8
|
+
}
|
|
9
|
+
const { role } = locals.user;
|
|
10
|
+
if (role !== EUserRole.ADMIN && role !== EUserRole.SUPER_ADMIN) {
|
|
11
|
+
throw redirect(303, DASHBOARD_ROUTE);
|
|
12
|
+
}
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
PeopleFill, BoxSeam, EnvelopeFill, CurrencyDollar,
|
|
4
|
+
ArrowUpRight, ArrowDownRight,
|
|
5
|
+
PersonPlusFill, ShieldLockFill, FileTextFill, GearFill,
|
|
6
|
+
} from 'svelte-bootstrap-icons';
|
|
7
|
+
|
|
8
|
+
const stats = [
|
|
9
|
+
{ label: 'Total Users', value: '2,847', change: '+12%', up: true, icon: PeopleFill, color: 'text-info', bg: 'bg-info/10' },
|
|
10
|
+
{ label: 'Orders', value: '1,293', change: '+8%', up: true, icon: BoxSeam, color: 'text-success', bg: 'bg-success/10' },
|
|
11
|
+
{ label: 'Messages', value: '348', change: '-3%', up: false, icon: EnvelopeFill, color: 'text-warning', bg: 'bg-warning/10' },
|
|
12
|
+
{ label: 'Revenue', value: '$48,200', change: '+21%', up: true, icon: CurrencyDollar, color: 'text-accent', bg: 'bg-accent/10' },
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const activity = [
|
|
16
|
+
{ user: 'Alice Johnson', action: 'Registered', time: '2 min ago', status: 'success' },
|
|
17
|
+
{ user: 'Bob Martinez', action: 'Placed order #8821', time: '15 min ago', status: 'info' },
|
|
18
|
+
{ user: 'Carol White', action: 'Sent message', time: '32 min ago', status: 'info' },
|
|
19
|
+
{ user: 'David Kim', action: 'Password reset', time: '1 hr ago', status: 'warning' },
|
|
20
|
+
{ user: 'Eva Nguyen', action: 'Account suspended', time: '3 hrs ago', status: 'error' },
|
|
21
|
+
{ user: 'Frank Schmidt', action: 'Profile updated', time: '5 hrs ago', status: 'success' },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const statusClass: Record<string, string> = {
|
|
25
|
+
success: 'bg-success/15 text-success',
|
|
26
|
+
info: 'bg-info/15 text-info',
|
|
27
|
+
warning: 'bg-warning/20 text-warning',
|
|
28
|
+
error: 'bg-error/15 text-error',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const actions = [
|
|
32
|
+
{ label: 'Add User', icon: PersonPlusFill, href: '/admin/users', color: 'text-info' },
|
|
33
|
+
{ label: 'Permissions', icon: ShieldLockFill, href: '/settings/security', color: 'text-accent' },
|
|
34
|
+
{ label: 'Content', icon: FileTextFill, href: '/content/pages', color: 'text-success' },
|
|
35
|
+
{ label: 'Settings', icon: GearFill, href: '/settings', color: 'text-warning' },
|
|
36
|
+
] as const;
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<div class="flex size-full flex-col overflow-y-auto p-6">
|
|
40
|
+
<div class="mb-6">
|
|
41
|
+
<h2 class="text-xl font-bold text-content-primary">Admin Dashboard</h2>
|
|
42
|
+
<p class="text-sm text-content-secondary mt-0.5">Overview of your application's key metrics.</p>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-8">
|
|
46
|
+
{#each stats as s}
|
|
47
|
+
<div class="bg-surface-primary rounded-xl border p-5 flex items-start gap-4 shadow-sm">
|
|
48
|
+
<div class="p-2.5 rounded-lg {s.bg} {s.color} shrink-0"><s.icon width={20} height={20} /></div>
|
|
49
|
+
<div class="min-w-0">
|
|
50
|
+
<p class="text-xs text-content-secondary font-medium truncate">{s.label}</p>
|
|
51
|
+
<p class="text-2xl font-bold text-content-primary leading-tight mt-0.5">{s.value}</p>
|
|
52
|
+
<span class="inline-flex items-center gap-0.5 text-xs font-medium mt-1 {s.up ? 'text-success' : 'text-error'}">
|
|
53
|
+
{#if s.up}<ArrowUpRight width={12} height={12} />{:else}<ArrowDownRight width={12} height={12} />{/if}
|
|
54
|
+
{s.change} this month
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
{/each}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
62
|
+
<div class="lg:col-span-2 bg-surface-primary rounded-xl border shadow-sm overflow-hidden">
|
|
63
|
+
<div class="px-5 py-4 border-b"><h3 class="text-sm font-semibold text-content-primary">Recent Activity</h3></div>
|
|
64
|
+
<div class="overflow-x-auto">
|
|
65
|
+
<table class="w-full text-sm">
|
|
66
|
+
<thead>
|
|
67
|
+
<tr class="bg-surface-secondary/50">
|
|
68
|
+
<th class="text-left px-5 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">User</th>
|
|
69
|
+
<th class="text-left px-4 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">Action</th>
|
|
70
|
+
<th class="text-left px-4 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">Time</th>
|
|
71
|
+
<th class="text-left px-4 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">Status</th>
|
|
72
|
+
</tr>
|
|
73
|
+
</thead>
|
|
74
|
+
<tbody>
|
|
75
|
+
{#each activity as row, i}
|
|
76
|
+
<tr class="border-t /60 {i % 2 === 1 ? 'bg-surface-secondary/20' : ''}">
|
|
77
|
+
<td class="px-5 py-3 font-medium text-content-primary whitespace-nowrap">{row.user}</td>
|
|
78
|
+
<td class="px-4 py-3 text-content-secondary">{row.action}</td>
|
|
79
|
+
<td class="px-4 py-3 text-content-tertiary whitespace-nowrap">{row.time}</td>
|
|
80
|
+
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded-full text-xs font-medium {statusClass[row.status]}">{row.status}</span></td>
|
|
81
|
+
</tr>
|
|
82
|
+
{/each}
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="bg-surface-primary rounded-xl border shadow-sm">
|
|
89
|
+
<div class="px-5 py-4 border-b"><h3 class="text-sm font-semibold text-content-primary">Quick Actions</h3></div>
|
|
90
|
+
<div class="p-4 flex flex-col gap-2">
|
|
91
|
+
{#each actions as a}
|
|
92
|
+
<a href={a.href} class="flex items-center gap-3 px-4 py-3 rounded-lg border hover:bg-surface-secondary transition-colors group">
|
|
93
|
+
<span class="{a.color} group-hover:scale-110 transition-transform"><a.icon width={18} height={18} /></span>
|
|
94
|
+
<span class="text-sm font-medium text-content-primary">{a.label}</span>
|
|
95
|
+
<ArrowUpRight width={13} height={13} class="ml-auto text-content-tertiary group-hover:text-content-secondary transition-colors" />
|
|
96
|
+
</a>
|
|
97
|
+
{/each}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Topbar } from '$widgets/topbar';
|
|
3
|
+
|
|
4
|
+
let { children } = $props();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<div class="flex h-screen w-screen flex-col overflow-hidden">
|
|
8
|
+
<Topbar />
|
|
9
|
+
<main class="relative min-h-0 flex-1 overflow-hidden">
|
|
10
|
+
{@render children()}
|
|
11
|
+
</main>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { ArrowClockwise } from 'svelte-bootstrap-icons';
|
|
3
|
+
import { snackStore, Form } from '@aryagg/ui-kit';
|
|
4
|
+
import { THEME_INPUTS } from '$features/theme-editor';
|
|
5
|
+
import type { IForm } from '@aryagg/types';
|
|
6
|
+
|
|
7
|
+
const initialForm: IForm = {
|
|
8
|
+
sections: [{ id: 'branding', fields: structuredClone(THEME_INPUTS) }]
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
let form = $state<IForm>(structuredClone(initialForm));
|
|
12
|
+
|
|
13
|
+
function saveTheme(_: FormData) { snackStore.showSuccess('Theme applied to page'); }
|
|
14
|
+
function resetTheme() {
|
|
15
|
+
form = structuredClone(initialForm);
|
|
16
|
+
snackStore.showInfo('Theme reset to saved state');
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<div class="bg-surface-secondary flex size-full flex-col overflow-hidden">
|
|
21
|
+
<div class="bg-surface-secondary shrink-0 px-10 py-2.5">
|
|
22
|
+
<div class="flex items-center gap-4">
|
|
23
|
+
<div>
|
|
24
|
+
<h1 class="text-primary text-3xl font-bold tracking-widest">Settings</h1>
|
|
25
|
+
<p class="text-tertiary pl-2 text-xs leading-tight">Title · description · logo · favicon</p>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="ml-auto flex items-center gap-1.5">
|
|
28
|
+
<button onclick={resetTheme} class="btn btn-sm">
|
|
29
|
+
<ArrowClockwise width="13" /> Reset
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="container mx-auto max-w-5xl flex-1 px-10 pt-2.5 pb-4">
|
|
36
|
+
<div class="card">
|
|
37
|
+
<h1 class="text-accent uppercase tracking-wide text-sm mb-4">Branding</h1>
|
|
38
|
+
<Form
|
|
39
|
+
bind:form
|
|
40
|
+
formClass="grid-cols-1 sm:grid-cols-2"
|
|
41
|
+
hideReset
|
|
42
|
+
hideCancel
|
|
43
|
+
submitLabel="Save & Apply"
|
|
44
|
+
onSubmit={saveTheme}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { resolve } from '$app/paths';
|
|
3
|
+
import * as m from '$lib/paraglide/messages';
|
|
4
|
+
import { EnvelopeFill } from 'svelte-bootstrap-icons';
|
|
5
|
+
|
|
6
|
+
let email = $state('');
|
|
7
|
+
let submitted = $state(false);
|
|
8
|
+
|
|
9
|
+
const handleSubmit = async (e: Event) => {
|
|
10
|
+
e.preventDefault();
|
|
11
|
+
// TODO: call authApi.forgotPassword(email)
|
|
12
|
+
submitted = true;
|
|
13
|
+
};
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<svelte:head>
|
|
17
|
+
<title>{m.forgot_title()}</title>
|
|
18
|
+
</svelte:head>
|
|
19
|
+
|
|
20
|
+
<div class="min-h-screen bg-surface-tertiary flex items-center justify-center px-4 py-12">
|
|
21
|
+
|
|
22
|
+
<!-- Ambient blob -->
|
|
23
|
+
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
24
|
+
<div class="absolute w-96 h-96 rounded-full bg-accent/5 top-0 right-0 blur-3xl"></div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="w-full max-w-md">
|
|
28
|
+
|
|
29
|
+
<!-- Logo -->
|
|
30
|
+
<a href={resolve("/",{})} class="flex items-center justify-center gap-2 mb-8">
|
|
31
|
+
<span class="text-accent text-xl leading-none">✦</span>
|
|
32
|
+
<span class="font-semibold text-content-primary tracking-wide text-lg">{m.app_name()}</span>
|
|
33
|
+
</a>
|
|
34
|
+
|
|
35
|
+
<div class="bg-surface-primary rounded-2xl shadow-xl border p-8">
|
|
36
|
+
|
|
37
|
+
{#if submitted}
|
|
38
|
+
<!-- Success state -->
|
|
39
|
+
<div class="flex flex-col items-center gap-4 text-center py-4">
|
|
40
|
+
<div class="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center">
|
|
41
|
+
<EnvelopeFill width={32} height={32} class="text-success" />
|
|
42
|
+
</div>
|
|
43
|
+
<h2 class="text-xl font-bold text-content-primary">{m.forgot_sent()}</h2>
|
|
44
|
+
<p class="text-sm text-content-secondary">{m.forgot_sent_hint()}</p>
|
|
45
|
+
<a href={resolve("/login",{})} class="btn btn-primary mt-4 w-full py-2.5">{m.forgot_back()}</a>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{:else}
|
|
49
|
+
<!-- Form state -->
|
|
50
|
+
<div class="mb-8 text-center">
|
|
51
|
+
<h1 class="text-2xl font-bold text-content-primary mb-1">{m.forgot_title()}</h1>
|
|
52
|
+
<p class="text-sm text-content-secondary">{m.forgot_subtitle()}</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<form onsubmit={handleSubmit} class="flex flex-col gap-5">
|
|
56
|
+
<div class="flex flex-col gap-1.5">
|
|
57
|
+
<label for="email" class="text-sm font-medium text-content-primary">
|
|
58
|
+
{m.register_email_label()}
|
|
59
|
+
</label>
|
|
60
|
+
<input
|
|
61
|
+
id="email"
|
|
62
|
+
type="email"
|
|
63
|
+
bind:value={email}
|
|
64
|
+
placeholder={m.register_email_placeholder()}
|
|
65
|
+
required
|
|
66
|
+
autocomplete="email"
|
|
67
|
+
class="w-full px-4 py-2.5 rounded-lg border bg-surface-secondary text-content-primary placeholder:text-content-tertiary focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<button type="submit" class="btn btn-primary w-full py-3 text-base font-semibold mt-1">
|
|
72
|
+
{m.forgot_submit()}
|
|
73
|
+
</button>
|
|
74
|
+
</form>
|
|
75
|
+
|
|
76
|
+
<p class="mt-6 text-center text-sm">
|
|
77
|
+
<a href={resolve("/login",{})} class="text-accent hover:underline">{m.forgot_back()}</a>
|
|
78
|
+
</p>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { fail, redirect } from '@sveltejs/kit';
|
|
2
|
+
import type { Actions } from '@sveltejs/kit';
|
|
3
|
+
import { AUTH_COOKIE_NAME, DASHBOARD_ROUTE } from '$shared/config';
|
|
4
|
+
import { PUBLIC_API_URL } from '$env/static/public';
|
|
5
|
+
import type { AuthToken, IAuthUser } from '$entities/auth';
|
|
6
|
+
|
|
7
|
+
export const actions: Actions = {
|
|
8
|
+
default: async ({ request, cookies, fetch }) => {
|
|
9
|
+
const form = await request.formData();
|
|
10
|
+
const email = String(form.get('email') ?? '').trim();
|
|
11
|
+
const password = String(form.get('password') ?? '');
|
|
12
|
+
|
|
13
|
+
if (!email || !password) {
|
|
14
|
+
return fail(400, { error: 'Email and password are required.' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── 1. Exchange credentials for tokens ────────────────────
|
|
18
|
+
let token: AuthToken;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${PUBLIC_API_URL}/auth/login`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ email, password }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (res.status === 401 || res.status === 403) {
|
|
27
|
+
return fail(401, { error: 'Invalid email or password.' });
|
|
28
|
+
}
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
return fail(res.status, { error: 'Login failed. Please try again.' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
token = await res.json() as AuthToken;
|
|
34
|
+
} catch {
|
|
35
|
+
return fail(503, { error: 'Cannot reach the server. Please try again later.' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── 2. Fetch the user's profile ───────────────────────────
|
|
39
|
+
let user: IAuthUser;
|
|
40
|
+
try {
|
|
41
|
+
const meRes = await fetch(`${PUBLIC_API_URL}/auth/me`, {
|
|
42
|
+
headers: { Authorization: `Bearer ${token.accessToken}` },
|
|
43
|
+
});
|
|
44
|
+
user = await meRes.json() as IAuthUser;
|
|
45
|
+
} catch {
|
|
46
|
+
return fail(503, { error: 'Could not load user profile. Please try again.' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 3. Write the session cookie ───────────────────────────
|
|
50
|
+
const session = {
|
|
51
|
+
user,
|
|
52
|
+
accessToken: token.accessToken,
|
|
53
|
+
expiresAt: Date.now() + token.expiresIn * 1000,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
cookies.set(AUTH_COOKIE_NAME, btoa(JSON.stringify(session)), {
|
|
57
|
+
path: '/',
|
|
58
|
+
httpOnly: true,
|
|
59
|
+
secure: process.env.NODE_ENV === 'production',
|
|
60
|
+
maxAge: token.expiresIn,
|
|
61
|
+
sameSite: 'lax',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
throw redirect(303, DASHBOARD_ROUTE);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as m from '$lib/paraglide/messages';
|
|
3
|
+
import { enhance } from '$app/forms';
|
|
4
|
+
import { Eye, EyeSlash } from 'svelte-bootstrap-icons';
|
|
5
|
+
import { resolve } from '$app/paths';
|
|
6
|
+
|
|
7
|
+
let { form }: { form?: { error?: string } | null } = $props();
|
|
8
|
+
|
|
9
|
+
let showPassword = $state(false);
|
|
10
|
+
let loading = $state(false);
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<svelte:head>
|
|
14
|
+
<title>{m.login_title()}</title>
|
|
15
|
+
</svelte:head>
|
|
16
|
+
|
|
17
|
+
<div class="min-h-screen bg-surface-tertiary flex items-center justify-center px-4 py-12">
|
|
18
|
+
|
|
19
|
+
<!-- Ambient blobs -->
|
|
20
|
+
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
21
|
+
<div class="absolute w-96 h-96 rounded-full bg-accent/5 -top-32 -left-32 blur-3xl"></div>
|
|
22
|
+
<div class="absolute w-80 h-80 rounded-full bg-info/5 -bottom-24 -right-24 blur-3xl"></div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="w-full max-w-md">
|
|
26
|
+
|
|
27
|
+
<!-- Logo -->
|
|
28
|
+
<a href={resolve("/",{})} class="flex items-center justify-center gap-2 mb-8">
|
|
29
|
+
<span class="text-accent text-xl leading-none">✦</span>
|
|
30
|
+
<span class="font-semibold text-content-primary tracking-wide text-lg">{m.app_name()}</span>
|
|
31
|
+
</a>
|
|
32
|
+
|
|
33
|
+
<!-- Card -->
|
|
34
|
+
<div class="bg-surface-primary rounded-2xl shadow-xl border p-8">
|
|
35
|
+
|
|
36
|
+
<!-- Heading -->
|
|
37
|
+
<div class="mb-8 text-center">
|
|
38
|
+
<h1 class="text-2xl font-bold text-content-primary mb-1">{m.login_title()}</h1>
|
|
39
|
+
<p class="text-sm text-content-secondary">{m.login_subtitle()}</p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Server error -->
|
|
43
|
+
{#if form?.error}
|
|
44
|
+
<div class="mb-5 px-4 py-3 rounded-lg bg-error/10 border border-error/30 text-sm text-error">
|
|
45
|
+
{form.error}
|
|
46
|
+
</div>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
<form
|
|
50
|
+
method="POST"
|
|
51
|
+
use:enhance={() => {
|
|
52
|
+
loading = true;
|
|
53
|
+
return async ({ update }) => {
|
|
54
|
+
loading = false;
|
|
55
|
+
await update();
|
|
56
|
+
};
|
|
57
|
+
}}
|
|
58
|
+
class="flex flex-col gap-5"
|
|
59
|
+
>
|
|
60
|
+
|
|
61
|
+
<!-- Email -->
|
|
62
|
+
<div class="flex flex-col gap-1.5">
|
|
63
|
+
<label for="email" class="text-sm font-medium text-content-primary">
|
|
64
|
+
{m.register_email_label()}
|
|
65
|
+
</label>
|
|
66
|
+
<input
|
|
67
|
+
id="email"
|
|
68
|
+
name="email"
|
|
69
|
+
type="email"
|
|
70
|
+
placeholder={m.register_email_placeholder()}
|
|
71
|
+
required
|
|
72
|
+
autocomplete="email"
|
|
73
|
+
class="w-full px-4 py-2.5 rounded-lg border bg-surface-secondary text-content-primary placeholder:text-content-tertiary focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<!-- Password -->
|
|
78
|
+
<div class="flex flex-col gap-1.5">
|
|
79
|
+
<div class="flex items-center justify-between">
|
|
80
|
+
<label for="password" class="text-sm font-medium text-content-primary">
|
|
81
|
+
{m.register_password_label()}
|
|
82
|
+
</label>
|
|
83
|
+
<a href={resolve("/forgot-password",{})} class="text-xs text-accent hover:underline">
|
|
84
|
+
{m.login_forgot_password()}
|
|
85
|
+
</a>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="relative">
|
|
88
|
+
<input
|
|
89
|
+
id="password"
|
|
90
|
+
name="password"
|
|
91
|
+
type={showPassword ? 'text' : 'password'}
|
|
92
|
+
placeholder={m.register_password_placeholder()}
|
|
93
|
+
required
|
|
94
|
+
autocomplete="current-password"
|
|
95
|
+
class="w-full px-4 py-2.5 pr-11 rounded-lg border bg-surface-secondary text-content-primary placeholder:text-content-tertiary focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent transition"
|
|
96
|
+
/>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onclick={() => showPassword = !showPassword}
|
|
100
|
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-primary transition"
|
|
101
|
+
aria-label="Toggle password visibility"
|
|
102
|
+
>
|
|
103
|
+
{#if showPassword}
|
|
104
|
+
<EyeSlash width={16} height={16} />
|
|
105
|
+
{:else}
|
|
106
|
+
<Eye width={16} height={16} />
|
|
107
|
+
{/if}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- Remember me -->
|
|
113
|
+
<label class="flex items-center gap-2.5 cursor-pointer group">
|
|
114
|
+
<input
|
|
115
|
+
type="checkbox"
|
|
116
|
+
name="rememberMe"
|
|
117
|
+
class="w-4 h-4 rounded accent-accent cursor-pointer"
|
|
118
|
+
/>
|
|
119
|
+
<span class="text-sm text-content-secondary group-hover:text-content-primary transition">
|
|
120
|
+
{m.login_remember_me()}
|
|
121
|
+
</span>
|
|
122
|
+
</label>
|
|
123
|
+
|
|
124
|
+
<!-- Submit -->
|
|
125
|
+
<button
|
|
126
|
+
type="submit"
|
|
127
|
+
disabled={loading}
|
|
128
|
+
class="btn btn-primary w-full py-3 text-base font-semibold mt-1 disabled:opacity-60"
|
|
129
|
+
>
|
|
130
|
+
{#if loading}
|
|
131
|
+
<span class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
|
132
|
+
{/if}
|
|
133
|
+
{m.login_submit()}
|
|
134
|
+
</button>
|
|
135
|
+
|
|
136
|
+
</form>
|
|
137
|
+
|
|
138
|
+
<!-- Divider -->
|
|
139
|
+
<div class="relative my-6">
|
|
140
|
+
<div class="absolute inset-0 flex items-center">
|
|
141
|
+
<div class="w-full border-t border-border-primary"></div>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="relative flex justify-center text-xs">
|
|
144
|
+
<span class="px-3 bg-surface-primary text-content-tertiary">{m.divider_text()}</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<!-- Sign up link -->
|
|
149
|
+
<p class="text-center text-sm text-content-secondary">
|
|
150
|
+
{m.login_no_account()}
|
|
151
|
+
<a href={resolve("/register",{})} class="text-accent font-medium hover:underline ml-1">{m.login_sign_up()}</a>
|
|
152
|
+
</p>
|
|
153
|
+
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|