mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
Add detailed documentation for Claude Code in .claude/ directory: - code-style.md: formatting, naming, linting rules - database.md: Drizzle ORM patterns and schema conventions - testing.md: Jest/Vitest patterns with mock factories - nestjs-backend.md: controller, service, DTO patterns - error-handling.md: Go-style Result types and error codes - sveltekit-web.md: Svelte 5 runes and store patterns - expo-mobile.md: React Native with NativeWind - authentication.md: Mana Core Auth integration Update root CLAUDE.md to reference new guidelines
17 KiB
17 KiB
SvelteKit Web Guidelines
Overview
All web applications use SvelteKit 2 with Svelte 5 in runes mode. This guide covers component patterns, state management, routing, and API integration.
Project Structure
apps/{project}/apps/web/
├── src/
│ ├── app.html # HTML template
│ ├── app.css # Global styles (Tailwind)
│ ├── app.d.ts # Type declarations
│ ├── hooks.server.ts # Server hooks (auth)
│ ├── lib/
│ │ ├── components/ # Reusable components
│ │ │ ├── ui/ # Generic UI components
│ │ │ └── {feature}/ # Feature-specific components
│ │ ├── stores/ # Svelte 5 stores (.svelte.ts)
│ │ ├── api/ # API client
│ │ ├── utils/ # Utilities
│ │ └── types/ # TypeScript types
│ └── routes/
│ ├── +layout.svelte # Root layout
│ ├── +page.svelte # Home page
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ └── register/
│ └── (protected)/ # Protected route group
│ ├── +layout.svelte
│ ├── files/
│ └── settings/
├── static/ # Static assets
├── svelte.config.js
├── vite.config.ts
├── tailwind.config.js
└── package.json
Svelte 5 Runes
State with $state
<script lang="ts">
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Object state
let user = $state<User | null>(null);
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
</script>
Derived Values with $derived
<script lang="ts">
let count = $state(0);
let items = $state<Item[]>([]);
// Computed value - updates automatically
const doubled = $derived(count * 2);
const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0);
// Complex derived
const sortedItems = $derived(
[...items].sort((a, b) => a.name.localeCompare(b.name))
);
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' :
count === 1 ? '1 item' :
`${count} items`
);
</script>
Effects with $effect
<script lang="ts">
import { browser } from '$app/environment';
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// Cleanup function
return () => clearTimeout(timer);
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
</script>
Props with $props
<script lang="ts">
import type { File } from '$lib/types';
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// Destructure with defaults
let {
file,
selected = false,
onDelete,
onSelect
}: Props = $props();
function handleDelete() {
onDelete?.(file.id);
}
</script>
<div class:selected onclick={() => onSelect?.(file)}>
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
Bindable Props with $bindable
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<input bind:value {disabled} />
<!-- Usage: -->
<!-- <TextInput bind:value={searchQuery} /> -->
Stores (Svelte 5 Pattern)
Store File (.svelte.ts)
// src/lib/stores/files.svelte.ts
import { browser } from '$app/environment';
import { api } from '$lib/api/client';
import type { File, AppError } from '$lib/types';
// Private state
let files = $state<File[]>([]);
let loading = $state(false);
let error = $state<AppError | null>(null);
let selectedId = $state<string | null>(null);
// Derived values
const selectedFile = $derived(
files.find(f => f.id === selectedId) ?? null
);
const fileCount = $derived(files.length);
// Actions
async function loadFiles(folderId?: string): Promise<void> {
if (!browser) return;
loading = true;
error = null;
const result = await api.files.list(folderId);
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
loading = false;
}
async function deleteFile(id: string): Promise<boolean> {
const result = await api.files.delete(id);
if (result.ok) {
files = files.filter(f => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
error = result.error;
return false;
}
function selectFile(id: string | null): void {
selectedId = id;
}
function reset(): void {
files = [];
loading = false;
error = null;
selectedId = null;
}
// Export as object with getters
export const fileStore = {
// Getters for state
get files() { return files; },
get loading() { return loading; },
get error() { return error; },
get selectedFile() { return selectedFile; },
get fileCount() { return fileCount; },
// Actions
loadFiles,
deleteFile,
selectFile,
reset,
};
Using Stores in Components
<script lang="ts">
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
onMount(() => {
fileStore.loadFiles();
});
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
</script>
{#if fileStore.loading}
<LoadingSpinner />
{:else if fileStore.error}
<ErrorMessage message={fileStore.error.message} />
{:else}
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
{/if}
API Client
// src/lib/api/client.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import type { Result, AppError } from '@manacore/shared-errors';
import { ErrorCode } from '@manacore/shared-errors';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
if (!browser) {
return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } };
}
try {
const token = authStore.token;
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
// Handle 401 - redirect to login
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } };
}
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return {
ok: false,
error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
};
}
}
// Typed API endpoints
export const api = {
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) =>
request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: UpdateFileDto) =>
request<File>(`/api/v1/files/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
folders: {
list: () => request<Folder[]>('/api/v1/folders'),
get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
create: (data: CreateFolderDto) =>
request<Folder>('/api/v1/folders', {
method: 'POST',
body: JSON.stringify(data),
}),
},
};
Routing
Route Groups
src/routes/
├── +layout.svelte # Root layout (applies to all)
├── +page.svelte # / (home)
├── (auth)/ # Auth pages (no sidebar)
│ ├── +layout.svelte # Auth layout
│ ├── login/+page.svelte
│ └── register/+page.svelte
└── (app)/ # App pages (with sidebar)
├── +layout.svelte # App layout with auth check
├── files/
│ ├── +page.svelte # /files
│ └── [id]/+page.svelte # /files/:id
└── settings/+page.svelte
Layout with Auth Check
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
let { children } = $props();
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.isAuthenticated}
<div class="flex h-screen">
<Sidebar />
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>
{:else}
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
{/if}
Dynamic Routes
<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api/client';
let file = $state<File | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
async function loadFile(id: string) {
loading = true;
error = null;
const result = await api.files.get(id);
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
loading = false;
}
</script>
{#if loading}
<LoadingSpinner />
{:else if error}
<ErrorMessage message={error} />
{:else if file}
<FileViewer {file} />
{/if}
Components
Component Pattern
<!-- src/lib/components/files/FileCard.svelte -->
<script lang="ts">
import type { File } from '$lib/types';
import { formatBytes, formatDate } from '$lib/utils/format';
import FileIcon from './FileIcon.svelte';
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
let { file, selected = false, onSelect, onDelete }: Props = $props();
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
</script>
<div
class="p-4 rounded-lg border transition-colors cursor-pointer
{selected ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}"
onclick={onSelect}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
>
<div class="flex items-start gap-3">
<FileIcon mimeType={file.mimeType} />
<div class="flex-1 min-w-0">
<h3 class="font-medium truncate">{file.name}</h3>
<p class="text-sm text-gray-500">
{formattedSize} • {formattedDate}
</p>
</div>
{#if onDelete}
<button
class="p-2 text-gray-400 hover:text-red-500"
onclick|stopPropagation={onDelete}
aria-label="Delete file"
>
<TrashIcon />
</button>
{/if}
</div>
</div>
Snippets (Slot Replacement)
<!-- Parent component -->
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
let showModal = $state(false);
</script>
<Modal bind:open={showModal}>
{#snippet header()}
<h2>Confirm Delete</h2>
{/snippet}
{#snippet content()}
<p>Are you sure you want to delete this file?</p>
{/snippet}
{#snippet footer()}
<button onclick={() => showModal = false}>Cancel</button>
<button onclick={handleDelete}>Delete</button>
{/snippet}
</Modal>
<!-- Modal.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
open: boolean;
header?: Snippet;
content?: Snippet;
footer?: Snippet;
}
let { open = $bindable(), header, content, footer }: Props = $props();
</script>
{#if open}
<div class="modal-overlay" onclick={() => open = false}>
<div class="modal" onclick|stopPropagation>
{#if header}
<div class="modal-header">{@render header()}</div>
{/if}
{#if content}
<div class="modal-content">{@render content()}</div>
{/if}
{#if footer}
<div class="modal-footer">{@render footer()}</div>
{/if}
</div>
</div>
{/if}
Styling
Tailwind Configuration
// tailwind.config.js
import sharedConfig from '@manacore/shared-tailwind';
export default {
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
};
Global Styles
/* src/app.css */
@import 'tailwindcss';
@import '@manacore/shared-tailwind/theme.css';
/* Custom utilities */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
}
/* Custom components */
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
}
}
Form Handling
<script lang="ts">
import { api } from '$lib/api/client';
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
// Client-side validation
if (!name.trim()) errors.name = 'Name is required';
if (!email.trim()) errors.email = 'Email is required';
if (Object.keys(errors).length > 0) return;
loading = true;
const result = await api.users.create({ name, email });
loading = false;
if (result.ok) {
goto('/users');
} else {
// Handle server errors
if (result.error.code === 'ERR_5002') {
errors.email = 'Email already exists';
} else {
errors.form = result.error.message;
}
}
}
</script>
<form onsubmit={handleSubmit}>
{#if errors.form}
<div class="text-red-500 mb-4">{errors.form}</div>
{/if}
<div class="mb-4">
<label for="name">Name</label>
<input id="name" bind:value={name} class:border-red-500={errors.name} />
{#if errors.name}
<span class="text-red-500 text-sm">{errors.name}</span>
{/if}
</div>
<div class="mb-4">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
{#if errors.email}
<span class="text-red-500 text-sm">{errors.email}</span>
{/if}
</div>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
</form>
Environment Variables
// Access in .svelte or .ts files
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// .env file
PUBLIC_BACKEND_URL=http://localhost:3016
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
Anti-Patterns to Avoid
Don't Use Old Svelte Syntax
<!-- BAD - Old Svelte 4 syntax -->
<script>
let count = 0;
$: doubled = count * 2;
$: console.log(count);
</script>
<!-- GOOD - Svelte 5 runes -->
<script>
let count = $state(0);
const doubled = $derived(count * 2);
$effect(() => console.log(count));
</script>
Don't Create Stores in Components
<!-- BAD - Store created in component -->
<script>
let store = $state({ items: [] }); // This is local, not shared
</script>
<!-- GOOD - Import store from .svelte.ts file -->
<script>
import { itemStore } from '$lib/stores/items.svelte';
</script>
Don't Fetch in Render
<!-- BAD - Fetches on every render -->
<script>
const promise = fetch('/api/data').then(r => r.json());
</script>
{#await promise}...{/await}
<!-- GOOD - Fetch in effect or onMount -->
<script>
import { onMount } from 'svelte';
let data = $state(null);
onMount(async () => {
data = await fetch('/api/data').then(r => r.json());
});
</script>