mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
Compare commits
@@ -0,0 +1,423 @@
|
||||
---
|
||||
name: drizzle-migrations
|
||||
description: Drizzle ORM schema management and SQLite migrations — adding tables, modifying columns, creating indexes, generating and running migrations, Drizzle query patterns. NOT for Prisma, TypeORM,
|
||||
Sequelize, or raw SQL migration tools.
|
||||
allowed-tools: Read,Write,Edit,Bash(npm:*,npx:*)
|
||||
metadata:
|
||||
category: Data & Analytics
|
||||
tags:
|
||||
- database
|
||||
- drizzle
|
||||
- migrations
|
||||
pairs-with:
|
||||
- skill: database-design-patterns
|
||||
reason: Schema migration implementation follows database design pattern decisions
|
||||
- skill: supabase-admin
|
||||
reason: Drizzle migrations run against Supabase PostgreSQL databases with RLS considerations
|
||||
- skill: fullstack-debugger
|
||||
reason: Migration failures and schema mismatches are common fullstack debugging scenarios
|
||||
---
|
||||
|
||||
# Drizzle ORM Migrations
|
||||
|
||||
This skill helps you manage database schema changes using Drizzle ORM with SQLite.
|
||||
|
||||
## When to Use
|
||||
|
||||
✅ **USE this skill for:**
|
||||
- Adding new tables or modifying existing columns
|
||||
- Generating and running database migrations
|
||||
- Drizzle-specific query patterns and relations
|
||||
- SQLite schema best practices with Drizzle
|
||||
- Setting up Drizzle configuration
|
||||
|
||||
❌ **DO NOT use for:**
|
||||
- Supabase/PostgreSQL → use `supabase-admin` skill
|
||||
- Raw SQL without Drizzle → use standard SQL resources
|
||||
- Prisma ORM → different syntax and patterns
|
||||
- General database design theory → use database architecture resources
|
||||
|
||||
## Project Setup
|
||||
|
||||
**Configuration**: `drizzle.config.ts`
|
||||
```typescript
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: './data/app.db',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Commands**:
|
||||
```bash
|
||||
npm run db:generate # Generate migration files
|
||||
npm run db:push # Push schema directly (dev only)
|
||||
npm run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Schema Definition
|
||||
|
||||
Location: `src/db/schema.ts`
|
||||
|
||||
### Table Definition
|
||||
|
||||
```typescript
|
||||
import { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// Basic table
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
username: text('username').notNull(),
|
||||
passwordHash: text('password_hash'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at'),
|
||||
});
|
||||
|
||||
// Table with foreign key
|
||||
export const checkIns = sqliteTable('check_ins', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, {
|
||||
onDelete: 'cascade',
|
||||
}),
|
||||
mood: integer('mood').notNull(),
|
||||
cravingLevel: integer('craving_level').notNull(),
|
||||
sleepHours: real('sleep_hours'),
|
||||
notes: text('notes'),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Table with composite index
|
||||
export const auditLog = sqliteTable('audit_log', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
action: text('action').notNull(),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
details: text('details'), // JSON string
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
}, (table) => ({
|
||||
userActionIdx: index('idx_audit_user_action').on(table.userId, table.action),
|
||||
createdAtIdx: index('idx_audit_created').on(table.createdAt),
|
||||
}));
|
||||
```
|
||||
|
||||
### Relations
|
||||
|
||||
```typescript
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
checkIns: many(checkIns),
|
||||
sessions: many(sessions),
|
||||
journalEntries: many(journalEntries),
|
||||
}));
|
||||
|
||||
export const checkInsRelations = relations(checkIns, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [checkIns.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
## Column Types
|
||||
|
||||
### SQLite Types in Drizzle
|
||||
|
||||
```typescript
|
||||
import {
|
||||
sqliteTable,
|
||||
text, // TEXT - strings, JSON, dates
|
||||
integer, // INTEGER - numbers, booleans (0/1)
|
||||
real, // REAL - floating point
|
||||
blob, // BLOB - binary data
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
|
||||
const examples = sqliteTable('examples', {
|
||||
// Strings
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
|
||||
// Numbers
|
||||
count: integer('count').notNull().default(0),
|
||||
rating: real('rating'),
|
||||
|
||||
// Booleans (stored as 0/1)
|
||||
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
||||
|
||||
// Dates (stored as ISO strings)
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text('expires_at'),
|
||||
|
||||
// JSON (stored as TEXT)
|
||||
metadata: text('metadata', { mode: 'json' }),
|
||||
|
||||
// Enums (stored as TEXT)
|
||||
status: text('status', { enum: ['pending', 'active', 'archived'] }),
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Strategies
|
||||
|
||||
### Strategy 1: Push (Development Only)
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
- Directly applies schema changes
|
||||
- Fast for development
|
||||
- **Never use in production**
|
||||
|
||||
### Strategy 2: Generate & Migrate (Production)
|
||||
|
||||
```bash
|
||||
# 1. Generate migration file
|
||||
npm run db:generate
|
||||
|
||||
# 2. Review generated SQL in /drizzle folder
|
||||
|
||||
# 3. Apply migration (in code or manually)
|
||||
```
|
||||
|
||||
### Applying Migrations in Code
|
||||
|
||||
```typescript
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const sqlite = new Database('./data/app.db');
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
// Run migrations
|
||||
migrate(db, { migrationsFolder: './drizzle' });
|
||||
```
|
||||
|
||||
## Common Schema Changes
|
||||
|
||||
### Adding a New Table
|
||||
|
||||
```typescript
|
||||
// 1. Add to schema.ts
|
||||
export const newFeature = sqliteTable('new_feature', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
name: text('name').notNull(),
|
||||
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// 2. Add relations
|
||||
export const newFeatureRelations = relations(newFeature, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [newFeature.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// 3. Generate migration
|
||||
// npm run db:generate
|
||||
```
|
||||
|
||||
### Adding a Column
|
||||
|
||||
```typescript
|
||||
// In schema.ts, add the new column
|
||||
export const users = sqliteTable('users', {
|
||||
// existing columns...
|
||||
newColumn: text('new_column'), // Add this
|
||||
});
|
||||
|
||||
// Generate migration
|
||||
// npm run db:generate
|
||||
```
|
||||
|
||||
### Adding an Index
|
||||
|
||||
```typescript
|
||||
export const messages = sqliteTable('messages', {
|
||||
id: text('id').primaryKey(),
|
||||
conversationId: text('conversation_id').notNull(),
|
||||
createdAt: text('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
// Add index
|
||||
convCreatedIdx: index('idx_messages_conv_created')
|
||||
.on(table.conversationId, table.createdAt),
|
||||
}));
|
||||
```
|
||||
|
||||
### Renaming (Requires Manual SQL)
|
||||
|
||||
SQLite doesn't support direct column renames in older versions. For complex changes:
|
||||
|
||||
```sql
|
||||
-- drizzle/XXXX_rename_column.sql
|
||||
-- Manual migration for column rename
|
||||
|
||||
-- 1. Create new table with desired schema
|
||||
CREATE TABLE users_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL, -- renamed from username
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Copy data
|
||||
INSERT INTO users_new SELECT id, email, username, created_at FROM users;
|
||||
|
||||
-- 3. Drop old table
|
||||
DROP TABLE users;
|
||||
|
||||
-- 4. Rename new table
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Basic Queries
|
||||
|
||||
```typescript
|
||||
import { db } from '@/db';
|
||||
import { eq, and, or, desc, asc, like, gte, lte } from 'drizzle-orm';
|
||||
import { users, checkIns } from '@/db/schema';
|
||||
|
||||
// Select all
|
||||
const allUsers = await db.select().from(users);
|
||||
|
||||
// Select with conditions
|
||||
const activeUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.isActive, true));
|
||||
|
||||
// Select specific columns
|
||||
const userEmails = await db
|
||||
.select({ id: users.id, email: users.email })
|
||||
.from(users);
|
||||
|
||||
// Complex where clause
|
||||
const results = await db
|
||||
.select()
|
||||
.from(checkIns)
|
||||
.where(
|
||||
and(
|
||||
eq(checkIns.userId, userId),
|
||||
gte(checkIns.createdAt, startDate),
|
||||
lte(checkIns.createdAt, endDate)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(checkIns.createdAt))
|
||||
.limit(30);
|
||||
```
|
||||
|
||||
### Insert
|
||||
|
||||
```typescript
|
||||
// Single insert
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: generateId(),
|
||||
email: 'user@example.com',
|
||||
username: 'newuser',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Bulk insert
|
||||
await db.insert(checkIns).values([
|
||||
{ id: '1', userId, mood: 7, cravingLevel: 2 },
|
||||
{ id: '2', userId, mood: 8, cravingLevel: 1 },
|
||||
]);
|
||||
|
||||
// Upsert (insert or update)
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ id: 'user-1', email: 'new@example.com' })
|
||||
.onConflictDoUpdate({
|
||||
target: users.id,
|
||||
set: { email: 'new@example.com' },
|
||||
});
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```typescript
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: 'newname', updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, userId));
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```typescript
|
||||
// Always use WHERE clause!
|
||||
await db
|
||||
.delete(checkIns)
|
||||
.where(eq(checkIns.id, checkInId));
|
||||
|
||||
// Delete with multiple conditions
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
lte(sessions.expiresAt, new Date().toISOString())
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Joins
|
||||
|
||||
```typescript
|
||||
const userWithCheckIns = await db
|
||||
.select({
|
||||
user: users,
|
||||
checkIn: checkIns,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(checkIns, eq(users.id, checkIns.userId))
|
||||
.where(eq(users.id, userId));
|
||||
```
|
||||
|
||||
### Aggregations
|
||||
|
||||
```typescript
|
||||
import { count, avg, sum, max, min } from 'drizzle-orm';
|
||||
|
||||
const stats = await db
|
||||
.select({
|
||||
totalCheckIns: count(),
|
||||
avgMood: avg(checkIns.mood),
|
||||
maxStreak: max(checkIns.streak),
|
||||
})
|
||||
.from(checkIns)
|
||||
.where(eq(checkIns.userId, userId));
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use transactions for related changes**
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(users).values(userData);
|
||||
await tx.insert(profiles).values(profileData);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Always include WHERE on DELETE/UPDATE**
|
||||
3. **Use indexes for frequently queried columns**
|
||||
4. **Store dates as ISO strings for SQLite**
|
||||
5. **Use `returning()` to get inserted/updated rows**
|
||||
6. **Generate migrations, don't push to production**
|
||||
|
||||
## References
|
||||
|
||||
- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview)
|
||||
- [Drizzle SQLite](https://orm.drizzle.team/docs/get-started-sqlite)
|
||||
- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: svelte5-best-practices
|
||||
description: "Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development. Use when writing, reviewing, or refactoring Svelte 5 components and SvelteKit applications. Triggers on: Svelte components, runes ($state, $derived, $effect, $props, $bindable, $inspect), snippets ({#snippet}, {@render}), event handling, SvelteKit data loading, form actions, Svelte 4 to Svelte 5 migration, store to rune migration, slots to snippets migration, TypeScript props typing, generic components, SSR state isolation, performance optimization, or component testing."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: ejirocodes
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# Svelte 5 Best Practices
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Topic | When to Use | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| **Runes** | $state, $derived, $effect, $props, $bindable, $inspect | [runes.md](references/runes.md) |
|
||||
| **Snippets** | Replacing slots, {#snippet}, {@render} | [snippets.md](references/snippets.md) |
|
||||
| **Events** | onclick handlers, callback props, context API | [events.md](references/events.md) |
|
||||
| **TypeScript** | Props typing, generic components | [typescript.md](references/typescript.md) |
|
||||
| **Migration** | Svelte 4 to 5, stores to runes | [migration.md](references/migration.md) |
|
||||
| **SvelteKit** | Load functions, form actions, SSR, page typing | [sveltekit.md](references/sveltekit.md) |
|
||||
| **Performance** | Universal reactivity, avoiding over-reactivity, streaming | [performance.md](references/performance.md) |
|
||||
|
||||
## Essential Patterns
|
||||
|
||||
### Reactive State
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0); // Reactive state
|
||||
let doubled = $derived(count * 2); // Computed value
|
||||
</script>
|
||||
```
|
||||
|
||||
### Component Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0 } = $props();
|
||||
let { value = $bindable() } = $props(); // Two-way binding
|
||||
</script>
|
||||
```
|
||||
|
||||
### Snippets (replacing slots)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children, header } = $props();
|
||||
</script>
|
||||
|
||||
{@render header?.()}
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```svelte
|
||||
<!-- Svelte 5: use onclick, not on:click -->
|
||||
<button onclick={() => count++}>Click</button>
|
||||
```
|
||||
|
||||
### Callback Props (replacing createEventDispatcher)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={() => onclick?.({ data })}>Click</button>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Using `let` without `$state`** - Variables are not reactive without `$state()`
|
||||
2. **Using `$effect` for derived values** - Use `$derived` instead
|
||||
3. **Using `on:click` syntax** - Use `onclick` in Svelte 5
|
||||
4. **Using `createEventDispatcher`** - Use callback props instead
|
||||
5. **Using `<slot>`** - Use snippets with `{@render}`
|
||||
6. **Forgetting `$bindable()`** - Required for `bind:` to work
|
||||
7. **Setting module-level state in SSR** - Causes cross-request leaks
|
||||
8. **Sequential awaits in load functions** - Use `Promise.all` for parallel requests
|
||||
@@ -0,0 +1,353 @@
|
||||
# Svelte 5 Events Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Event Handler Syntax](#event-handler-syntax)
|
||||
- [Callback Props Pattern](#callback-props-pattern)
|
||||
- [Context API](#context-api)
|
||||
|
||||
---
|
||||
|
||||
## Event Handler Syntax
|
||||
|
||||
Svelte 5 replaces `on:click` directive syntax with standard HTML attribute syntax `onclick`.
|
||||
|
||||
### Basic Event Handlers
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<button on:click={handleClick}>Click</button>
|
||||
<input on:input={handleInput} />
|
||||
<form on:submit={handleSubmit}>...</form>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<button onclick={handleClick}>Click</button>
|
||||
<input oninput={handleInput} />
|
||||
<form onsubmit={handleSubmit}>...</form>
|
||||
```
|
||||
|
||||
### Event Modifiers Migration
|
||||
|
||||
Event modifiers no longer exist. Use wrapper functions:
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<form on:submit|preventDefault={handleSubmit}>...</form>
|
||||
<button on:click|stopPropagation={handleClick}>...</button>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
// ... handle form
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
event.stopPropagation();
|
||||
// ... handle click
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit}>...</form>
|
||||
<button onclick={handleClick}>...</button>
|
||||
```
|
||||
|
||||
### Capture, Passive, and NonPassive
|
||||
|
||||
```svelte
|
||||
<!-- Capture phase -->
|
||||
<div onclickcapture={handleCapture}>
|
||||
<button onclick={handleClick}>Click</button>
|
||||
</div>
|
||||
|
||||
<!-- Passive listener -->
|
||||
<div ontouchstartpassive={handleTouch}>...</div>
|
||||
|
||||
<!-- Non-passive -->
|
||||
<div ontouchmovenonpassive={(e) => e.preventDefault()}>...</div>
|
||||
```
|
||||
|
||||
### Inline Handlers
|
||||
|
||||
```svelte
|
||||
<button onclick={() => count++}>Count: {count}</button>
|
||||
<input oninput={(e) => name = e.target.value} />
|
||||
```
|
||||
|
||||
### Event Handler Shorthand
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
function onclick(event) {
|
||||
console.log('Clicked!', event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button {onclick}>Click</button>
|
||||
```
|
||||
|
||||
### Spreading Event Handlers
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let handlers = {
|
||||
onclick: () => console.log('clicked'),
|
||||
onmouseenter: () => console.log('entered'),
|
||||
onmouseleave: () => console.log('left')
|
||||
};
|
||||
</script>
|
||||
|
||||
<button {...handlers}>Hover or Click</button>
|
||||
```
|
||||
|
||||
### Multiple Handlers for Same Event
|
||||
|
||||
In Svelte 5, combine logic into one handler:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
function handleClick(event) {
|
||||
handler1(event);
|
||||
handler2(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={handleClick}>...</button>
|
||||
```
|
||||
|
||||
### TypeScript Event Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
function handleClick(event: MouseEvent) {
|
||||
console.log(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
console.log(target.value);
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget as HTMLFormElement);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callback Props Pattern
|
||||
|
||||
Svelte 5 deprecates `createEventDispatcher` in favor of callback props for component-to-parent communication.
|
||||
|
||||
### Basic Event Pattern
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<button on:click={() => dispatch('click', { timestamp: Date.now() })}>Click</button>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Button on:click={(e) => console.log(e.detail)} />
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<!-- Button.svelte -->
|
||||
<script>
|
||||
let { onclick } = $props();
|
||||
</script>
|
||||
|
||||
<button onclick={() => onclick?.({ timestamp: Date.now() })}>Click</button>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Button onclick={(data) => console.log(data)} />
|
||||
```
|
||||
|
||||
### Multiple Callbacks
|
||||
|
||||
```svelte
|
||||
<!-- Dialog.svelte -->
|
||||
<script>
|
||||
let { onconfirm, oncancel, onclose } = $props();
|
||||
</script>
|
||||
|
||||
<dialog>
|
||||
<button onclick={() => onconfirm?.()}>Confirm</button>
|
||||
<button onclick={() => oncancel?.()}>Cancel</button>
|
||||
<button onclick={() => onclose?.()}>X</button>
|
||||
</dialog>
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<Dialog
|
||||
onconfirm={() => save()}
|
||||
oncancel={() => reset()}
|
||||
onclose={() => visible = false}
|
||||
/>
|
||||
```
|
||||
|
||||
### Typed Callbacks with TypeScript
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
onsearch?: (query: string) => void;
|
||||
onchange?: (value: string) => void;
|
||||
onclear?: () => void;
|
||||
}
|
||||
|
||||
let { value = '', onsearch, onchange, onclear }: Props = $props();
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const newValue = (e.target as HTMLInputElement).value;
|
||||
value = newValue;
|
||||
onchange?.(newValue);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Forwarding Native Events
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { MouseEventHandler } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { onclick, children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button {onclick} {...rest}>{@render children()}</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context API
|
||||
|
||||
Context functions must be called synchronously during component initialization.
|
||||
|
||||
### Correct Context Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { setContext, getContext } from 'svelte';
|
||||
|
||||
// These run during initialization - CORRECT
|
||||
setContext('theme', 'dark');
|
||||
const theme = getContext('theme');
|
||||
|
||||
// INCORRECT - in $effect or callback
|
||||
// $effect(() => { setContext('theme', 'dark'); }); // ERROR
|
||||
</script>
|
||||
```
|
||||
|
||||
### Reactive Context Values
|
||||
|
||||
Pass `$state` objects for reactive context:
|
||||
|
||||
```svelte
|
||||
<!-- Provider.svelte -->
|
||||
<script>
|
||||
import { setContext } from 'svelte';
|
||||
|
||||
let theme = $state('light');
|
||||
|
||||
setContext('theme', {
|
||||
get current() { return theme; },
|
||||
toggle() { theme = theme === 'light' ? 'dark' : 'light'; }
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<!-- Consumer.svelte -->
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
const themeContext = getContext('theme');
|
||||
</script>
|
||||
|
||||
<p>Theme: {themeContext.current}</p>
|
||||
<button onclick={themeContext.toggle}>Toggle</button>
|
||||
```
|
||||
|
||||
### Type-Safe Context Pattern
|
||||
|
||||
```ts
|
||||
// context.ts
|
||||
import { setContext, getContext } from 'svelte';
|
||||
|
||||
const THEME_KEY = Symbol('theme');
|
||||
|
||||
interface ThemeContext {
|
||||
current: 'light' | 'dark';
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export function setThemeContext(context: ThemeContext) {
|
||||
setContext(THEME_KEY, context);
|
||||
}
|
||||
|
||||
export function getThemeContext(): ThemeContext {
|
||||
const context = getContext<ThemeContext>(THEME_KEY);
|
||||
if (!context) throw new Error('Theme context not found');
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Generic Keys
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// Option 1: Symbol
|
||||
const USER_KEY = Symbol('user');
|
||||
setContext(USER_KEY, userData);
|
||||
|
||||
// Option 2: Unique string
|
||||
setContext('myapp:user', userData);
|
||||
|
||||
// Option 3: Object key
|
||||
const userKey = {};
|
||||
setContext(userKey, userData);
|
||||
</script>
|
||||
```
|
||||
|
||||
### hasContext Check
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { hasContext, getContext } from 'svelte';
|
||||
|
||||
const hasTheme = hasContext('theme');
|
||||
const theme = hasTheme ? getContext('theme') : { current: 'light' };
|
||||
</script>
|
||||
```
|
||||
|
||||
### Context Boundaries
|
||||
|
||||
Context flows down the component tree:
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import { getContext, setContext } from 'svelte';
|
||||
const level = getContext('level'); // 0
|
||||
setContext('level', level + 1); // Override for children
|
||||
</script>
|
||||
|
||||
<Child /> <!-- Will see level = 1 -->
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# Svelte 4 to Svelte 5 Migration Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Migrating Reactive Statements](#migrating-reactive-statements)
|
||||
- [Migrating Stores to Runes](#migrating-stores-to-runes)
|
||||
|
||||
---
|
||||
|
||||
## Migrating Reactive Statements
|
||||
|
||||
Svelte 5 replaces `$:` with `$derived` (for values) and `$effect` (for side effects).
|
||||
|
||||
### Computed Values: $: to $derived
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = 0;
|
||||
$: doubled = count * 2;
|
||||
$: quadrupled = doubled * 2;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let quadrupled = $derived(doubled * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Complex Computed Values: $derived.by
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = [];
|
||||
let filter = 'all';
|
||||
|
||||
$: filteredItems = {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'active') return items.filter(i => !i.done);
|
||||
return items.filter(i => i.done);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([]);
|
||||
let filter = $state('all');
|
||||
|
||||
let filteredItems = $derived.by(() => {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'active') return items.filter(i => !i.done);
|
||||
return items.filter(i => i.done);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Side Effects: $: to $effect
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = 0;
|
||||
$: console.log('Count changed:', count);
|
||||
$: document.title = `Count: ${count}`;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => { console.log('Count changed:', count); });
|
||||
$effect(() => { document.title = `Count: ${count}`; });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Conditional Side Effects
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
let value;
|
||||
$: if (value > 100) { alert('Value too high!'); }
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let value = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (value > 100) { alert('Value too high!'); }
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props Migration
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
export let name;
|
||||
export let count = 0;
|
||||
$: greeting = `Hello, ${name}!`;
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0 } = $props();
|
||||
let greeting = $derived(`Hello, ${name}!`);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effect with Cleanup
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let count = 0;
|
||||
let interval;
|
||||
|
||||
$: {
|
||||
clearInterval(interval);
|
||||
interval = setInterval(() => console.log(count), 1000);
|
||||
}
|
||||
|
||||
onDestroy(() => clearInterval(interval));
|
||||
</script>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => console.log(count), 1000);
|
||||
return () => clearInterval(interval); // Cleanup built-in
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Migration Cheat Sheet
|
||||
|
||||
| Svelte 4 Pattern | Svelte 5 Replacement |
|
||||
|------------------|----------------------|
|
||||
| `let x = 0` (in component) | `let x = $state(0)` |
|
||||
| `export let prop` | `let { prop } = $props()` |
|
||||
| `$: derived = expr` | `let derived = $derived(expr)` |
|
||||
| `$: { complex }` (value) | `let x = $derived.by(() => { ... })` |
|
||||
| `$: console.log(x)` | `$effect(() => console.log(x))` |
|
||||
| `$: if (x) { ... }` | `$effect(() => { if (x) { ... } })` |
|
||||
| `$: document.title = x` | `$effect(() => { document.title = x })` |
|
||||
|
||||
### Automated Migration
|
||||
|
||||
```bash
|
||||
npx sv migrate svelte-5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrating Stores to Runes
|
||||
|
||||
Svelte 5 runes can replace most store use cases with simpler, more direct reactive state.
|
||||
|
||||
### Local Component State
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<script>
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const count = writable(0);
|
||||
function increment() { count.update(n => n + 1); }
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>Count: {$count}</button>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
function increment() { count++; }
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>Count: {count}</button>
|
||||
```
|
||||
|
||||
### Shared State Across Components
|
||||
|
||||
**Svelte 4 (stores.ts):**
|
||||
```ts
|
||||
import { writable } from 'svelte/store';
|
||||
export const user = writable(null);
|
||||
export const theme = writable('light');
|
||||
```
|
||||
|
||||
**Svelte 5 (state.svelte.ts):**
|
||||
```ts
|
||||
export const user = $state<User | null>(null);
|
||||
export const theme = $state({ current: 'light' as 'light' | 'dark' });
|
||||
|
||||
export function setTheme(newTheme: 'light' | 'dark') {
|
||||
theme.current = newTheme;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { theme, setTheme } from './state.svelte';
|
||||
</script>
|
||||
|
||||
<p>Theme: {theme.current}</p>
|
||||
<button onclick={() => setTheme('dark')}>Dark Mode</button>
|
||||
```
|
||||
|
||||
### Derived Store to $derived
|
||||
|
||||
**Svelte 4:**
|
||||
```ts
|
||||
import { writable, derived } from 'svelte/store';
|
||||
export const items = writable([]);
|
||||
export const completedCount = derived(items, $items =>
|
||||
$items.filter(i => i.done).length
|
||||
);
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// state.svelte.ts
|
||||
export const items = $state<Item[]>([]);
|
||||
|
||||
export function getCompletedCount() {
|
||||
return items.filter(i => i.done).length;
|
||||
}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { items } from './state.svelte';
|
||||
let completedCount = $derived(items.filter(i => i.done).length);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Custom Store to Reactive Class
|
||||
|
||||
**Svelte 4:**
|
||||
```ts
|
||||
function createCounter() {
|
||||
const { subscribe, set, update } = writable(0);
|
||||
return {
|
||||
subscribe,
|
||||
increment: () => update(n => n + 1),
|
||||
decrement: () => update(n => n - 1),
|
||||
reset: () => set(0)
|
||||
};
|
||||
}
|
||||
export const counter = createCounter();
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// counter.svelte.ts
|
||||
class Counter {
|
||||
value = $state(0);
|
||||
increment() { this.value++; }
|
||||
decrement() { this.value--; }
|
||||
reset() { this.value = 0; }
|
||||
}
|
||||
|
||||
export const counter = new Counter();
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { counter } from './counter.svelte';
|
||||
</script>
|
||||
|
||||
<p>{counter.value}</p>
|
||||
<button onclick={() => counter.increment()}>+</button>
|
||||
```
|
||||
|
||||
### Async State Pattern
|
||||
|
||||
**Svelte 5:**
|
||||
```ts
|
||||
// api.svelte.ts
|
||||
export const state = $state({
|
||||
data: null as Data | null,
|
||||
loading: false,
|
||||
error: null as Error | null
|
||||
});
|
||||
|
||||
export async function fetchData() {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/data');
|
||||
state.data = await res.json();
|
||||
} catch (e) {
|
||||
state.error = e as Error;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When to Still Use Stores
|
||||
|
||||
1. **Library interop** - Libraries that expect Svelte stores
|
||||
2. **Legacy code** - Gradual migration
|
||||
3. **Observable patterns** - When you need the subscribe API
|
||||
4. **Server-side rendering** - Stores have built-in SSR handling
|
||||
|
||||
### Store to Rune Cheat Sheet
|
||||
|
||||
| Store Pattern | Rune Replacement |
|
||||
|---------------|------------------|
|
||||
| `writable(value)` | `$state(value)` |
|
||||
| `$store` (auto-subscribe) | Direct access to `$state` value |
|
||||
| `store.set(x)` | `state = x` |
|
||||
| `store.update(fn)` | Direct mutation or reassignment |
|
||||
| `derived(store, fn)` | `$derived(fn())` in component |
|
||||
| `readable(value, start)` | `$state` + `$effect` for setup |
|
||||
@@ -0,0 +1,443 @@
|
||||
# Svelte 5 Performance Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Universal Reactivity](#universal-reactivity)
|
||||
- [Avoiding Over-Reactivity](#avoiding-over-reactivity)
|
||||
- [Preventing Load Waterfalls](#preventing-load-waterfalls)
|
||||
- [Streaming Non-Critical Data](#streaming-non-critical-data)
|
||||
- [Component Testing](#component-testing)
|
||||
|
||||
---
|
||||
|
||||
## Universal Reactivity
|
||||
|
||||
Svelte 5 allows runes in `.svelte.js` or `.svelte.ts` files for shared state outside components.
|
||||
|
||||
### Shared Counter State
|
||||
|
||||
```ts
|
||||
// counter.svelte.ts
|
||||
export const counter = $state({ count: 0 });
|
||||
|
||||
export function increment() { counter.count++; }
|
||||
export function decrement() { counter.count--; }
|
||||
export function reset() { counter.count = 0; }
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { counter, increment } from './counter.svelte';
|
||||
</script>
|
||||
|
||||
<p>Count: {counter.count}</p>
|
||||
<button onclick={increment}>+</button>
|
||||
```
|
||||
|
||||
### Object State with Getters
|
||||
|
||||
```ts
|
||||
// user.svelte.ts
|
||||
const state = $state<User>({ firstName: '', lastName: '', email: '' });
|
||||
|
||||
export const user = {
|
||||
get firstName() { return state.firstName; },
|
||||
set firstName(v: string) { state.firstName = v; },
|
||||
get fullName() { return `${state.firstName} ${state.lastName}`; },
|
||||
get isValid() { return state.firstName && state.email.includes('@'); }
|
||||
};
|
||||
```
|
||||
|
||||
### Reactive Class Pattern
|
||||
|
||||
```ts
|
||||
// todo.svelte.ts
|
||||
class TodoStore {
|
||||
items = $state<Todo[]>([]);
|
||||
filter = $state<'all' | 'active' | 'completed'>('all');
|
||||
|
||||
get filtered() {
|
||||
switch (this.filter) {
|
||||
case 'active': return this.items.filter(t => !t.done);
|
||||
case 'completed': return this.items.filter(t => t.done);
|
||||
default: return this.items;
|
||||
}
|
||||
}
|
||||
|
||||
add(text: string) {
|
||||
this.items.push({ id: Date.now(), text, done: false });
|
||||
}
|
||||
}
|
||||
|
||||
export const todos = new TodoStore();
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
1. **File extension matters**: Must use `.svelte.js` or `.svelte.ts`
|
||||
2. **No $derived in module scope**: Use getters for derived values
|
||||
3. **SSR caution**: Avoid initializing browser-only state at module level
|
||||
|
||||
---
|
||||
|
||||
## Avoiding Over-Reactivity
|
||||
|
||||
Common mistakes with runes can cause unnecessary re-renders, infinite loops, or degraded performance.
|
||||
|
||||
### Anti-Pattern 1: Using $effect to Set Derived Values
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $state(0);
|
||||
$effect(() => { doubled = count * 2; }); // Anti-pattern!
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 2: Circular Dependencies
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let a = $state(1);
|
||||
let b = $state(2);
|
||||
|
||||
$effect(() => {
|
||||
if (a > 10) b = 0;
|
||||
if (b > 10) a = 0; // Triggers the effect again!
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT: Use separate effects or event handlers**
|
||||
|
||||
### Anti-Pattern 3: Not Using untrack
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let log = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
log.push(`Count is ${count}`); // log change triggers re-run!
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let count = $state(0);
|
||||
let log = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => { log.push(`Count is ${count}`); });
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 4: Heavy Computations in $derived
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([/* thousands */]);
|
||||
let filter = $state('');
|
||||
|
||||
let filtered = $derived(
|
||||
items.filter(item => JSON.stringify(item).includes(filter))
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT: Debounce expensive computations**
|
||||
```svelte
|
||||
<script>
|
||||
let filter = $state('');
|
||||
let debouncedFilter = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const timeout = setTimeout(() => { debouncedFilter = filter; }, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
items.filter(item => item.name.includes(debouncedFilter))
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Anti-Pattern 5: Effect for DOM Manipulation
|
||||
|
||||
**WRONG:**
|
||||
```svelte
|
||||
<script>
|
||||
let visible = $state(false);
|
||||
let element;
|
||||
|
||||
$effect(() => {
|
||||
if (element) element.style.display = visible ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```svelte
|
||||
{#if visible}<div>Content</div>{/if}
|
||||
<!-- Or -->
|
||||
<div class:hidden={!visible}>Content</div>
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. Use `$derived` over `$effect` for computed values
|
||||
2. Debounce expensive reactive computations
|
||||
3. Use `untrack` to prevent unnecessary dependencies
|
||||
4. Group related state into objects
|
||||
5. Let Svelte handle DOM updates
|
||||
|
||||
---
|
||||
|
||||
## Preventing Load Waterfalls
|
||||
|
||||
Sequential API calls multiply latency. Use parallel requests and streaming.
|
||||
|
||||
### Anti-Pattern: Waterfall
|
||||
|
||||
**WRONG (3 seconds total):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json()); // 1s
|
||||
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json()); // 1s
|
||||
const comments = await fetch('/api/comments').then(r => r.json()); // 1s
|
||||
return { user, posts, comments };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 1: Parallel with Promise.all
|
||||
|
||||
**CORRECT (1 second total):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const [user, posts, comments] = await Promise.all([
|
||||
fetch('/api/user').then(r => r.json()),
|
||||
fetch('/api/posts').then(r => r.json()),
|
||||
fetch('/api/comments').then(r => r.json())
|
||||
]);
|
||||
return { user, posts, comments };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Partial Dependencies
|
||||
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
|
||||
const [posts, followers] = await Promise.all([
|
||||
fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
|
||||
fetch(`/api/users/${user.id}/followers`).then(r => r.json())
|
||||
]);
|
||||
|
||||
return { user, posts, followers };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Streaming Non-Critical Data
|
||||
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
|
||||
return {
|
||||
user,
|
||||
recommendations: fetch('/api/recommendations').then(r => r.json()),
|
||||
analytics: fetch('/api/analytics').then(r => r.json())
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Performance Comparison
|
||||
|
||||
| Pattern | 3 APIs x 1s each |
|
||||
|---------|------------------|
|
||||
| Sequential | 3 seconds |
|
||||
| Parallel (Promise.all) | 1 second |
|
||||
| Streaming | 0s initial, streams rest |
|
||||
|
||||
---
|
||||
|
||||
## Streaming Non-Critical Data
|
||||
|
||||
Return promises from load functions to stream non-essential data after initial page render.
|
||||
|
||||
### Basic Streaming
|
||||
|
||||
**WRONG (blocks on slow data):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json()); // 100ms
|
||||
const analytics = await fetch('/api/analytics').then(r => r.json()); // 2000ms
|
||||
return { user, analytics }; // Page blocked for 2.1 seconds
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT (stream slow data):**
|
||||
```ts
|
||||
export const load = async ({ fetch }) => {
|
||||
const user = await fetch('/api/user').then(r => r.json());
|
||||
const analytics = fetch('/api/analytics').then(r => r.json()); // Don't await
|
||||
|
||||
return {
|
||||
user, // Available in 100ms
|
||||
analytics // Streams when ready
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Component Handling
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<header><h1>Welcome, {data.user.name}</h1></header>
|
||||
|
||||
<aside>
|
||||
{#await data.analytics}
|
||||
<div class="skeleton">Loading analytics...</div>
|
||||
{:then analytics}
|
||||
<div class="analytics">
|
||||
<p>Page views: {analytics.views}</p>
|
||||
</div>
|
||||
{:catch}
|
||||
<div class="error">Failed to load analytics</div>
|
||||
{/await}
|
||||
</aside>
|
||||
```
|
||||
|
||||
### When to Stream
|
||||
|
||||
| Data Type | Stream? | Reason |
|
||||
|-----------|---------|--------|
|
||||
| User info | No | Critical for layout |
|
||||
| Main content | No | Users came for this |
|
||||
| Analytics | Yes | Not user-facing |
|
||||
| Recommendations | Yes | Supplementary |
|
||||
| Comments | Maybe | Important but can load later |
|
||||
|
||||
---
|
||||
|
||||
## Component Testing
|
||||
|
||||
Svelte 5 components with runes require proper Vitest configuration.
|
||||
|
||||
### Option 1: Vitest Browser Mode (Recommended)
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
name: 'chromium'
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2: JSDOM with @testing-library/svelte
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
globals: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Basic Component Test
|
||||
|
||||
```ts
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { expect, test } from 'vitest';
|
||||
import Counter from './Counter.svelte';
|
||||
|
||||
test('increments count when clicked', async () => {
|
||||
render(Counter);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent('Count: 0');
|
||||
|
||||
await fireEvent.click(button);
|
||||
expect(button).toHaveTextContent('Count: 1');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Props
|
||||
|
||||
```ts
|
||||
test('renders with custom name', () => {
|
||||
render(Greeting, { props: { name: 'Alice' } });
|
||||
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Callbacks
|
||||
|
||||
```ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
test('calls onclick callback when clicked', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(Button, { props: { onclick: handleClick } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Components
|
||||
|
||||
```ts
|
||||
import { waitFor } from '@testing-library/svelte';
|
||||
|
||||
test('loads and displays user data', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'Alice' })
|
||||
});
|
||||
|
||||
render(UserProfile, { props: { userId: '123' } });
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,394 @@
|
||||
# Svelte 5 Runes Reference
|
||||
|
||||
## Table of Contents
|
||||
- [$state - Reactive State](#state---reactive-state)
|
||||
- [$derived - Computed Values](#derived---computed-values)
|
||||
- [$effect - Side Effects](#effect---side-effects)
|
||||
- [$props - Component Props](#props---component-props)
|
||||
- [$bindable - Two-Way Binding](#bindable---two-way-binding)
|
||||
- [$inspect - Debugging](#inspect---debugging)
|
||||
|
||||
---
|
||||
|
||||
## $state - Reactive State
|
||||
|
||||
In Svelte 5, plain `let` declarations are no longer automatically reactive. Use `$state()` for reactive state.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>
|
||||
Clicks: {count}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Object and Array State
|
||||
|
||||
Objects and arrays are deeply reactive by default:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let user = $state({ name: 'Alice', age: 30 });
|
||||
let items = $state(['apple', 'banana']);
|
||||
</script>
|
||||
|
||||
<button onclick={() => user.age++}>Age: {user.age}</button>
|
||||
<button onclick={() => items.push('cherry')}>Items: {items.length}</button>
|
||||
```
|
||||
|
||||
### Class State
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
class Counter {
|
||||
count = $state(0);
|
||||
increment() { this.count++; }
|
||||
}
|
||||
const counter = new Counter();
|
||||
</script>
|
||||
|
||||
<button onclick={() => counter.increment()}>{counter.count}</button>
|
||||
```
|
||||
|
||||
### Raw State (Opt-out of Deep Reactivity)
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state.raw([1, 2, 3]);
|
||||
|
||||
// This WON'T trigger an update:
|
||||
items.push(4);
|
||||
|
||||
// This WILL trigger an update:
|
||||
items = [...items, 4];
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $derived - Computed Values
|
||||
|
||||
Replaces `$:` reactive statements for computed values.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let doubled = $derived(count * 2);
|
||||
let quadrupled = $derived(doubled * 2);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Complex Derivations with $derived.by
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([1, 2, 3, 4, 5]);
|
||||
let filter = $state('even');
|
||||
|
||||
let filteredItems = $derived.by(() => {
|
||||
if (filter === 'even') return items.filter(n => n % 2 === 0);
|
||||
return items.filter(n => n % 2 !== 0);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Deriving from Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { firstName, lastName } = $props();
|
||||
let fullName = $derived(`${firstName} ${lastName}`);
|
||||
</script>
|
||||
```
|
||||
|
||||
### $derived vs $effect
|
||||
|
||||
Use `$derived` for computing values, `$effect` for side effects:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
// CORRECT: Use $derived for computed values
|
||||
let doubled = $derived(count * 2);
|
||||
|
||||
// INCORRECT: Don't use $effect to set derived values
|
||||
// let doubled;
|
||||
// $effect(() => { doubled = count * 2; }); // Anti-pattern!
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $effect - Side Effects
|
||||
|
||||
Runs code when component mounts and when dependencies change. Requires cleanup for subscriptions.
|
||||
|
||||
### Basic Effect with Cleanup
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => count++, 1000);
|
||||
return () => clearInterval(interval); // Cleanup
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### DOM Event Listeners
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
function handleMouseMove(event) {
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
}
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### External Subscriptions
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { userId } = $props();
|
||||
let userData = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const unsubscribe = database.subscribe(`users/${userId}`, (data) => {
|
||||
userData = data;
|
||||
});
|
||||
return () => unsubscribe();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### $effect.pre for Pre-DOM Updates
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let div;
|
||||
let messages = $state([]);
|
||||
|
||||
$effect.pre(() => {
|
||||
if (div) {
|
||||
const shouldScroll = div.scrollTop + div.clientHeight >= div.scrollHeight - 20;
|
||||
if (shouldScroll) {
|
||||
tick().then(() => { div.scrollTop = div.scrollHeight; });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Untracked Dependencies
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let count = $state(0);
|
||||
let logCount = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
// Only runs when count changes, not logCount
|
||||
console.log(count, untrack(() => logCount));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $props - Component Props
|
||||
|
||||
Replaces `export let` for declaring component props.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { name, count = 0, disabled = false } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Rest Props Pattern
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { class: className, children, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<div class={className} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Renaming Reserved Words
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let {
|
||||
class: className, // 'class' is reserved
|
||||
for: htmlFor, // 'for' is reserved
|
||||
...rest
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### TypeScript Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
count?: number;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { name, count = 0, onClick }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Props with Children
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children, footer }: Props = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<h1>{title}</h1>
|
||||
<main>{@render children()}</main>
|
||||
{#if footer}<footer>{@render footer()}</footer>{/if}
|
||||
</article>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $bindable - Two-Way Binding
|
||||
|
||||
Props must be explicitly marked with `$bindable()` to support `bind:`.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<!-- Input.svelte -->
|
||||
<script>
|
||||
let { value = $bindable() } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Input from './Input.svelte';
|
||||
let name = $state('');
|
||||
</script>
|
||||
|
||||
<Input bind:value={name} />
|
||||
```
|
||||
|
||||
### Bindable with Default Value
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { value = $bindable('default') } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Bindable Props
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let {
|
||||
value = $bindable(''),
|
||||
checked = $bindable(false),
|
||||
selected = $bindable(null)
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### TypeScript with Bindable
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), disabled = false }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## $inspect - Debugging
|
||||
|
||||
Development-only debugging that logs values when they change. Stripped in production.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
$inspect(count); // Logs every time count changes
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multiple Values
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let name = $state('Alice');
|
||||
let age = $state(30);
|
||||
$inspect(name, age);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Custom Logging with $inspect.with
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let user = $state({ name: 'Alice', age: 30 });
|
||||
|
||||
$inspect.with((type, ...values) => {
|
||||
if (type === 'init') console.log('Initial value:', values);
|
||||
else console.log('Updated to:', values);
|
||||
}, user);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Debugging with Breakpoints
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let data = $state({ x: 0, y: 0 });
|
||||
|
||||
$inspect.with((type, value) => {
|
||||
if (type === 'update' && value.x > 100) debugger;
|
||||
}, data);
|
||||
</script>
|
||||
```
|
||||
@@ -0,0 +1,291 @@
|
||||
# Svelte 5 Snippets Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Replacing Slots with Snippets](#replacing-slots-with-snippets)
|
||||
- [Rendering with @render](#rendering-with-render)
|
||||
|
||||
---
|
||||
|
||||
## Replacing Slots with Snippets
|
||||
|
||||
Svelte 5 replaces `<slot>` with snippets - a more powerful and type-safe composition primitive.
|
||||
|
||||
### Default Content (children)
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<div class="card"><slot /></div>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">{@render children?.()}</div>
|
||||
```
|
||||
|
||||
### Named Slots to Named Snippets
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<header><slot name="header" /></header>
|
||||
<main><slot /></main>
|
||||
<footer><slot name="footer" /></footer>
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
<h1 slot="header">Title</h1>
|
||||
<p>Content</p>
|
||||
<span slot="footer">Footer</span>
|
||||
</Layout>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { header, children, footer } = $props();
|
||||
</script>
|
||||
|
||||
<header>{@render header?.()}</header>
|
||||
<main>{@render children?.()}</main>
|
||||
<footer>{@render footer?.()}</footer>
|
||||
|
||||
<!-- Usage -->
|
||||
<Layout>
|
||||
{#snippet header()}<h1>Title</h1>{/snippet}
|
||||
<p>Content</p>
|
||||
{#snippet footer()}<span>Footer</span>{/snippet}
|
||||
</Layout>
|
||||
```
|
||||
|
||||
### Slot Props to Snippet Parameters
|
||||
|
||||
**Svelte 4:**
|
||||
```svelte
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li><slot {item} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<List {items} let:item>
|
||||
<span>{item.name}</span>
|
||||
</List>
|
||||
```
|
||||
|
||||
**Svelte 5:**
|
||||
```svelte
|
||||
<script>
|
||||
let { items, children } = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item}
|
||||
<li>{@render children?.(item)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<List {items}>
|
||||
{#snippet children(item)}
|
||||
<span>{item.name}</span>
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Slot Fallback to Snippet Fallback
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<p>Default content</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Defining Snippets Within Components
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let items = $state([
|
||||
{ id: 1, name: 'Item 1', type: 'normal' },
|
||||
{ id: 2, name: 'Item 2', type: 'featured' }
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#snippet normalItem(item)}
|
||||
<li>{item.name}</li>
|
||||
{/snippet}
|
||||
|
||||
{#snippet featuredItem(item)}
|
||||
<li class="featured">*** {item.name} ***</li>
|
||||
{/snippet}
|
||||
|
||||
<ul>
|
||||
{#each items as item}
|
||||
{#if item.type === 'featured'}
|
||||
{@render featuredItem(item)}
|
||||
{:else}
|
||||
{@render normalItem(item)}
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### TypeScript Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Item { id: number; name: string; }
|
||||
|
||||
interface Props {
|
||||
header?: Snippet;
|
||||
children: Snippet<[item: Item]>;
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let { header, children, footer }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering with @render
|
||||
|
||||
Snippets must be rendered using `{@render}`. Optional snippets need null-safe calling.
|
||||
|
||||
### Basic Snippet Rendering
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div>{@render children()}</div>
|
||||
```
|
||||
|
||||
### Optional Snippets
|
||||
|
||||
Always use optional chaining for snippets that might not be provided:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { header, children, footer } = $props();
|
||||
</script>
|
||||
|
||||
{#if header}
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
|
||||
<main>{@render children?.()}</main>
|
||||
|
||||
{#if footer}
|
||||
<footer>{@render footer()}</footer>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Rendering with Arguments
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { items, itemTemplate } = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item, index}
|
||||
<li>{@render itemTemplate(item, index)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Usage -->
|
||||
<List {items}>
|
||||
{#snippet itemTemplate(item, index)}
|
||||
<span>{index + 1}. {item.name}</span>
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Rendering Multiple Times
|
||||
|
||||
Snippets can be rendered multiple times:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { icon } = $props();
|
||||
</script>
|
||||
|
||||
<button>
|
||||
{@render icon?.()}
|
||||
<span>Click me</span>
|
||||
{@render icon?.()}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Rendering Dynamic Snippets
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { normalView, editView, isEditing } = $props();
|
||||
let currentView = $derived(isEditing ? editView : normalView);
|
||||
</script>
|
||||
|
||||
{@render currentView?.()}
|
||||
```
|
||||
|
||||
### TypeScript with @render
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
header?: Snippet;
|
||||
row: Snippet<[data: { id: number; name: string }]>;
|
||||
}
|
||||
|
||||
let { children, header, row }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@render header?.()}
|
||||
{@render children()}
|
||||
{@render row({ id: 1, name: 'Test' })}
|
||||
```
|
||||
|
||||
### Passing Snippets to Child Components
|
||||
|
||||
```svelte
|
||||
<!-- Wrapper.svelte -->
|
||||
<script>
|
||||
import Inner from './Inner.svelte';
|
||||
let { itemRenderer } = $props();
|
||||
</script>
|
||||
|
||||
<Inner {itemRenderer} />
|
||||
|
||||
<!-- Inner.svelte -->
|
||||
<script>
|
||||
let { itemRenderer } = $props();
|
||||
</script>
|
||||
|
||||
{#each items as item}
|
||||
{@render itemRenderer?.(item)}
|
||||
{/each}
|
||||
```
|
||||
@@ -0,0 +1,401 @@
|
||||
# SvelteKit Patterns Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Load Function Types](#load-function-types)
|
||||
- [Page Props Typing](#page-props-typing)
|
||||
- [Form Actions Error Handling](#form-actions-error-handling)
|
||||
- [SSR State Isolation](#ssr-state-isolation)
|
||||
|
||||
---
|
||||
|
||||
## Load Function Types
|
||||
|
||||
SvelteKit has two load function types: universal (`+page.js`) and server-only (`+page.server.ts`).
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Use +page.server.ts | Use +page.js |
|
||||
|---------------------|--------------|
|
||||
| Accessing secrets/credentials | Public APIs only |
|
||||
| Database connections | Non-serializable data (functions, classes) |
|
||||
| Server-only APIs | Client-side caching benefits |
|
||||
| Sensitive business logic | |
|
||||
|
||||
### Security: Secrets in Server Load
|
||||
|
||||
**WRONG (secrets exposed):**
|
||||
```ts
|
||||
// +page.js - DANGEROUS: runs in browser!
|
||||
export const load = async ({ fetch }) => {
|
||||
const response = await fetch('https://api.stripe.com/charges', {
|
||||
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } // EXPOSED!
|
||||
});
|
||||
return { charges: await response.json() };
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
// +page.server.ts - only runs on server
|
||||
import { STRIPE_SECRET_KEY } from '$env/static/private';
|
||||
|
||||
export const load = async ({ fetch }) => {
|
||||
const response = await fetch('https://api.stripe.com/charges', {
|
||||
headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` }
|
||||
});
|
||||
return { charges: await response.json() };
|
||||
};
|
||||
```
|
||||
|
||||
### Serialization: Non-serializable in Universal Load
|
||||
|
||||
**WRONG (server load can't serialize functions):**
|
||||
```ts
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
return {
|
||||
formatDate: (date: Date) => date.toLocaleDateString(), // ERROR
|
||||
parser: new DOMParser() // ERROR
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
// +page.js
|
||||
export const load = async () => {
|
||||
return {
|
||||
formatDate: (date: Date) => date.toLocaleDateString(), // OK
|
||||
parser: typeof window !== 'undefined' ? new DOMParser() : null
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Database Access
|
||||
|
||||
```ts
|
||||
// +page.server.ts - CORRECT
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
export const load = async () => {
|
||||
const users = await db.query('SELECT * FROM users');
|
||||
return { users };
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```ts
|
||||
// +page.server.ts - Private env vars
|
||||
import { DATABASE_URL, API_SECRET } from '$env/static/private';
|
||||
|
||||
// +page.js - Only public env vars
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Props Typing
|
||||
|
||||
SvelteKit generates types in `./$types` for full type safety with `$props()`.
|
||||
|
||||
### Basic Page Data Typing
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<h1>{data.title}</h1>
|
||||
```
|
||||
|
||||
### Page Data with Form Actions
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
{#if form?.success}
|
||||
<p class="success">{form.message}</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### Layout Data Typing
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
{#if data.user}<span>Welcome, {data.user.name}</span>{/if}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
### Corresponding Load Function
|
||||
|
||||
```ts
|
||||
// +page.server.ts
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
title: 'My Page',
|
||||
items: await fetchItems()
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
return { success: true, message: 'Saved successfully' };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Error Page
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<h1>{page.status}: {page.error?.message}</h1>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Form Actions Error Handling
|
||||
|
||||
SvelteKit distinguishes between validation errors (`fail()`) and unexpected errors (`throw error()`).
|
||||
|
||||
### Use fail() for Validation Errors
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
if (!email?.toString().includes('@')) {
|
||||
throw error(400, 'Invalid email'); // Shows error page!
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString() ?? '';
|
||||
|
||||
if (!email.includes('@')) {
|
||||
return fail(400, {
|
||||
error: 'Invalid email address',
|
||||
email // Return for repopulation
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Use throw error() for Unexpected Errors
|
||||
|
||||
```ts
|
||||
import { fail, error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
// Validation - use fail()
|
||||
if (!data.get('title')) {
|
||||
return fail(400, { error: 'Title is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await database.save(data);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
// Unexpected error - use throw
|
||||
throw error(500, 'Unable to save. Please try again later.');
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Structured Validation Responses
|
||||
|
||||
```ts
|
||||
interface FormErrors {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
register: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const errors: FormErrors = {};
|
||||
|
||||
if (!email) errors.email = 'Email is required';
|
||||
if (password.length < 8) errors.password = 'Password must be at least 8 characters';
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return fail(400, { errors, values: { email } });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Handle in Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/register" use:enhance>
|
||||
<label>
|
||||
Email
|
||||
<input name="email" value={form?.values?.email ?? ''} />
|
||||
{#if form?.errors?.email}
|
||||
<span class="error">{form.errors.email}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Error Response Summary
|
||||
|
||||
| Situation | Function | Result |
|
||||
|-----------|----------|--------|
|
||||
| Missing field | `fail(400, {...})` | Form state preserved |
|
||||
| Invalid format | `fail(400, {...})` | Form state preserved |
|
||||
| Not found | `throw error(404, ...)` | Error page |
|
||||
| Server crash | `throw error(500, ...)` | Error page |
|
||||
| Auth required | `throw redirect(303, ...)` | Redirect |
|
||||
|
||||
---
|
||||
|
||||
## SSR State Isolation
|
||||
|
||||
Shared server state persists across requests, potentially leaking data between users.
|
||||
|
||||
### Dangerous: Module-Level State
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
// +page.server.ts
|
||||
let currentUser = null; // SHARED ACROSS ALL REQUESTS!
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
currentUser = locals.user; // User B overwrites User A
|
||||
return { user: currentUser };
|
||||
};
|
||||
```
|
||||
|
||||
**CORRECT:**
|
||||
```ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user }; // Each request gets its own locals
|
||||
};
|
||||
```
|
||||
|
||||
### Dangerous: Global Stores
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
// stores.svelte.ts
|
||||
export const user = $state<User | null>(null); // Server-side singleton!
|
||||
```
|
||||
|
||||
**CORRECT: Use locals and return data from load**
|
||||
```ts
|
||||
// +layout.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
### Safe Patterns
|
||||
|
||||
**Pattern 1: Use locals**
|
||||
```ts
|
||||
// hooks.server.ts
|
||||
export const handle = async ({ event, resolve }) => {
|
||||
event.locals.user = await authenticate(event);
|
||||
event.locals.requestId = crypto.randomUUID();
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
// +page.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern 2: Context for Component Trees**
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
setContext('user', {
|
||||
get current() { return data.user; }
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
**Pattern 3: Client-Only State**
|
||||
```ts
|
||||
// stores.svelte.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
function createClientStore() {
|
||||
if (!browser) return { value: null };
|
||||
const state = $state({ value: null });
|
||||
return state;
|
||||
}
|
||||
|
||||
export const clientState = createClientStore();
|
||||
```
|
||||
|
||||
### SSR Safety Checklist
|
||||
|
||||
- [ ] No module-level `let` variables that store user data
|
||||
- [ ] No global `$state` that gets set during SSR
|
||||
- [ ] No singleton service classes with mutable state
|
||||
- [ ] All user-specific data flows through `locals`
|
||||
- [ ] All page data comes from load function returns
|
||||
@@ -0,0 +1,305 @@
|
||||
# Svelte 5 TypeScript Reference
|
||||
|
||||
## Table of Contents
|
||||
- [Props Typing](#props-typing)
|
||||
- [Generic Components](#generic-components)
|
||||
|
||||
---
|
||||
|
||||
## Props Typing
|
||||
|
||||
`$props()` requires specific patterns for TypeScript typing.
|
||||
|
||||
### Basic Props Typing
|
||||
|
||||
**Inline typing:**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { name, count = 0 }: { name: string; count?: number } = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
**Interface typing (recommended):**
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name: string;
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { name, count = 0, disabled = false }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Children and Snippets Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet; // Required children
|
||||
header?: Snippet; // Optional snippet
|
||||
row: Snippet<[data: RowData]>; // Snippet with parameter
|
||||
cell?: Snippet<[value: string, index: number]>; // Multiple params
|
||||
}
|
||||
|
||||
interface RowData { id: number; name: string; }
|
||||
|
||||
let { children, header, row, cell }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Callback Props Typing
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange?: (value: string) => void;
|
||||
onsubmit?: (data: FormData) => Promise<void>;
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { value, onchange, onsubmit, onclick }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Rest Props with HTML Attributes
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let { variant = 'primary', loading = false, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class={variant} disabled={loading} {...rest}>
|
||||
{#if loading}Loading...{:else}{@render children?.()}{/if}
|
||||
</button>
|
||||
```
|
||||
|
||||
### Input Element Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<HTMLInputAttributes, 'value'> {
|
||||
value?: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), label, error, ...rest }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Union Type Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'primary', size = 'md', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button class="{variant} {size}">{@render children()}</button>
|
||||
```
|
||||
|
||||
### Discriminated Union Props
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
type Props =
|
||||
| { type: 'link'; href: string; children: Snippet }
|
||||
| { type: 'button'; onclick: () => void; children: Snippet };
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if props.type === 'link'}
|
||||
<a href={props.href}>{@render props.children()}</a>
|
||||
{:else}
|
||||
<button onclick={props.onclick}>{@render props.children()}</button>
|
||||
{/if}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Components
|
||||
|
||||
Svelte 5 uses the `generics` attribute for type-safe reusable components.
|
||||
|
||||
### Basic Generic Component
|
||||
|
||||
```svelte
|
||||
<!-- List.svelte -->
|
||||
<script lang="ts" generics="T">
|
||||
interface Props {
|
||||
items: T[];
|
||||
children: import('svelte').Snippet<[item: T, index: number]>;
|
||||
}
|
||||
|
||||
let { items, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each items as item, index}
|
||||
<li>{@render children(item, index)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Usage -->
|
||||
<script lang="ts">
|
||||
import List from './List.svelte';
|
||||
|
||||
interface User { id: number; name: string; }
|
||||
let users: User[] = $state([{ id: 1, name: 'Alice' }]);
|
||||
</script>
|
||||
|
||||
<List items={users}>
|
||||
{#snippet children(user, index)}
|
||||
<span>{index + 1}. {user.name}</span> <!-- Fully typed! -->
|
||||
{/snippet}
|
||||
</List>
|
||||
```
|
||||
|
||||
### Multiple Type Parameters
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="K, V">
|
||||
interface Props {
|
||||
entries: [K, V][];
|
||||
renderKey: import('svelte').Snippet<[key: K]>;
|
||||
renderValue: import('svelte').Snippet<[value: V]>;
|
||||
}
|
||||
|
||||
let { entries, renderKey, renderValue }: Props = $props();
|
||||
</script>
|
||||
|
||||
<dl>
|
||||
{#each entries as [key, value]}
|
||||
<dt>{@render renderKey(key)}</dt>
|
||||
<dd>{@render renderValue(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
```
|
||||
|
||||
### Constrained Generics
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T extends { id: string | number }">
|
||||
interface Props {
|
||||
items: T[];
|
||||
selected?: T;
|
||||
onselect?: (item: T) => void;
|
||||
children: import('svelte').Snippet<[item: T, isSelected: boolean]>;
|
||||
}
|
||||
|
||||
let { items, selected, onselect, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each items as item}
|
||||
<div
|
||||
class:selected={selected?.id === item.id}
|
||||
onclick={() => onselect?.(item)}
|
||||
>
|
||||
{@render children(item, selected?.id === item.id)}
|
||||
</div>
|
||||
{/each}
|
||||
```
|
||||
|
||||
### Generic with Default Type
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T = Record<string, unknown>">
|
||||
interface Props {
|
||||
data: T[];
|
||||
columns: (keyof T)[];
|
||||
}
|
||||
|
||||
let { data, columns }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Generic Select Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T">
|
||||
interface Props {
|
||||
options: T[];
|
||||
value?: T;
|
||||
getLabel: (option: T) => string;
|
||||
getValue: (option: T) => string;
|
||||
onchange?: (selected: T | undefined) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
value = $bindable(),
|
||||
getLabel,
|
||||
getValue,
|
||||
onchange,
|
||||
placeholder = 'Select...'
|
||||
}: Props = $props();
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const selectedValue = (e.target as HTMLSelectElement).value;
|
||||
const selected = options.find(o => getValue(o) === selectedValue);
|
||||
value = selected;
|
||||
onchange?.(selected);
|
||||
}
|
||||
</script>
|
||||
|
||||
<select onchange={handleChange}>
|
||||
<option value="">{placeholder}</option>
|
||||
{#each options as option}
|
||||
<option
|
||||
value={getValue(option)}
|
||||
selected={value && getValue(value) === getValue(option)}
|
||||
>
|
||||
{getLabel(option)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
```
|
||||
|
||||
### Generic Async Component
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
promise: Promise<T>;
|
||||
loading?: Snippet;
|
||||
error?: Snippet<[error: Error]>;
|
||||
children: Snippet<[data: T]>;
|
||||
}
|
||||
|
||||
let { promise, loading, error, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#await promise}
|
||||
{#if loading}{@render loading()}{:else}<p>Loading...</p>{/if}
|
||||
{:then data}
|
||||
{@render children(data)}
|
||||
{:catch err}
|
||||
{#if error}{@render error(err)}{:else}<p>Error: {err.message}</p>{/if}
|
||||
{/await}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sveltekit Data Flow
|
||||
|
||||
SvelteKit data flow guidance. Use for load functions, form actions,
|
||||
and server/client data. Covers +page.server.ts vs +page.ts,
|
||||
serialization, fail(), redirect(), error().
|
||||
|
||||
## Structure
|
||||
|
||||
- `SKILL.md` - Main skill instructions
|
||||
- `references/` - Detailed documentation loaded as needed
|
||||
- `scripts/` - Executable code for deterministic operations
|
||||
- `assets/` - Templates, images, or other resources
|
||||
|
||||
## Usage
|
||||
|
||||
This skill is automatically discovered by Claude when relevant to the
|
||||
task.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: sveltekit-data-flow
|
||||
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
|
||||
# prettier-ignore
|
||||
description: SvelteKit data flow guidance. Use for load functions, form actions, server/client data, and invalidation. Covers +page.server.ts vs +page.ts, serialization, fail(), redirect(), error(), invalidateAll().
|
||||
---
|
||||
|
||||
# SvelteKit Data Flow
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Which file?** Server-only (DB/secrets): `+page.server.ts` |
|
||||
Universal (runs both): `+page.ts` | API: `+server.ts`
|
||||
|
||||
**Load decision:** Need server resources? → server load | Need client
|
||||
APIs? → universal load
|
||||
|
||||
**Form actions:** Always `+page.server.ts`. Return `fail()` for
|
||||
errors, throw `redirect()` to navigate, throw `error()` for failures.
|
||||
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
const user = await db.users.get(locals.userId);
|
||||
return { user }; // Must be JSON-serializable
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
|
||||
if (!email) return fail(400, { email, missing: true });
|
||||
|
||||
await updateEmail(email);
|
||||
throw redirect(303, '/success');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [load-functions.md](references/load-functions.md) - Server vs
|
||||
universal
|
||||
- [form-actions.md](references/form-actions.md) - Form handling
|
||||
patterns
|
||||
- [serialization.md](references/serialization.md) - What can/can't
|
||||
serialize
|
||||
- [error-redirect-handling.md](references/error-redirect-handling.md) -
|
||||
fail/redirect/error
|
||||
- [client-auth-invalidation.md](references/client-auth-invalidation.md) -
|
||||
invalidateAll() after client-side auth
|
||||
|
||||
## Notes
|
||||
|
||||
- Server load → universal load via `data` param | ALWAYS
|
||||
`throw redirect()/error()`
|
||||
- No class instances/functions from server load (not serializable)
|
||||
- **Last verified:** 2025-01-11
|
||||
|
||||
<!--
|
||||
PROGRESSIVE DISCLOSURE GUIDELINES:
|
||||
- Keep this file ~50 lines total (max ~150 lines)
|
||||
- Use 1-2 code blocks only (recommend 1)
|
||||
- Keep description <200 chars for Level 1 efficiency
|
||||
- Move detailed docs to references/ for Level 3 loading
|
||||
- This is Level 2 - quick reference ONLY, not a manual
|
||||
|
||||
LLM WORKFLOW (when editing this file):
|
||||
1. Write/edit SKILL.md
|
||||
2. Format (if formatter available)
|
||||
3. Run: claude-skills-cli validate <path>
|
||||
4. If multi-line description warning: run claude-skills-cli doctor <path>
|
||||
5. Validate again to confirm
|
||||
-->
|
||||
@@ -0,0 +1,125 @@
|
||||
# Client-Side Auth Invalidation
|
||||
|
||||
When using client-side auth libraries (Better Auth, Firebase, Supabase client),
|
||||
layout server data doesn't auto-refresh after login/logout.
|
||||
|
||||
## The Problem
|
||||
|
||||
```typescript
|
||||
// signin/+page.svelte - BROKEN
|
||||
async function handle_signin() {
|
||||
await auth_client.signIn.email({ email, password });
|
||||
goto('/'); // Layout still shows "logged out"
|
||||
}
|
||||
```
|
||||
|
||||
**Why?** Client-side navigation (`goto()`) doesn't re-run server load functions.
|
||||
The session cookie is set, but `+layout.server.ts` data is stale.
|
||||
|
||||
## Solution A: Inline Invalidation (Simple)
|
||||
|
||||
Call `invalidateAll()` after auth state change:
|
||||
|
||||
```typescript
|
||||
// signin/+page.svelte
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
|
||||
async function handle_signin() {
|
||||
const result = await auth_client.signIn.email({ email, password });
|
||||
if (result.error) return;
|
||||
|
||||
await invalidateAll(); // Re-runs ALL load functions
|
||||
goto('/');
|
||||
}
|
||||
|
||||
// signout handler
|
||||
async function handle_signout() {
|
||||
await auth_client.signOut();
|
||||
await invalidateAll();
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:** Single auth entry point, simple apps.
|
||||
|
||||
## Solution B: Auth State Listener (Robust)
|
||||
|
||||
Set up a listener in root layout that auto-invalidates on auth changes:
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth_client } from '$lib/auth-client';
|
||||
|
||||
onMount(() => {
|
||||
// Listen for auth state changes
|
||||
const unsubscribe = auth_client.onAuthStateChange(() => {
|
||||
invalidateAll();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Use when:** Multiple auth flows (OAuth, magic links, etc.), complex apps.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Not awaiting invalidateAll()
|
||||
|
||||
```typescript
|
||||
// WRONG - race condition
|
||||
invalidateAll();
|
||||
goto('/');
|
||||
|
||||
// RIGHT - wait for data refresh
|
||||
await invalidateAll();
|
||||
goto('/');
|
||||
```
|
||||
|
||||
### Destructuring layout data
|
||||
|
||||
```svelte
|
||||
<!-- WRONG - static snapshot -->
|
||||
<script>
|
||||
let { data } = $props();
|
||||
const { user } = data; // Never updates after invalidateAll()
|
||||
</script>
|
||||
|
||||
<!-- RIGHT - reactive access -->
|
||||
<script>
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{data.user?.email}
|
||||
```
|
||||
|
||||
## When invalidateAll() Runs
|
||||
|
||||
Per [Svelte docs](https://svelte.dev/tutorial/kit/invalidate-all):
|
||||
|
||||
> `invalidateAll()` is the nuclear option - it re-runs ALL load functions
|
||||
> for the current page, regardless of dependencies.
|
||||
|
||||
This includes:
|
||||
- `+layout.server.ts` (all levels)
|
||||
- `+layout.ts` (all levels)
|
||||
- `+page.server.ts`
|
||||
- `+page.ts`
|
||||
|
||||
## Comparison with Server-Side Auth
|
||||
|
||||
| Approach | Auth Location | Invalidation |
|
||||
|----------|--------------|--------------|
|
||||
| Form actions | Server (`+page.server.ts`) | Automatic (page reload) |
|
||||
| Client auth | Browser (auth_client) | Manual (`invalidateAll()`) |
|
||||
|
||||
Form actions with `throw redirect()` cause a full navigation, which
|
||||
naturally re-runs load functions. Client-side auth with `goto()` does not.
|
||||
|
||||
## See Also
|
||||
|
||||
- [error-redirect-handling.md](error-redirect-handling.md) - Server-side redirects
|
||||
- [load-functions.md](load-functions.md) - Server vs universal loads
|
||||
@@ -0,0 +1,407 @@
|
||||
# Error & Redirect Handling: fail(), redirect(), error()
|
||||
|
||||
## The Three Ways to Handle Failures
|
||||
|
||||
| Function | When | Must Throw? | Use Case |
|
||||
| ------------ | ---------------- | ----------- | ------------------------------------- |
|
||||
| `fail()` | Validation error | No (return) | Form validation errors |
|
||||
| `redirect()` | Navigate user | **YES** | After successful action |
|
||||
| `error()` | Fatal error | **YES** | Unauthorized, not found, server error |
|
||||
|
||||
## fail() - Validation Errors
|
||||
|
||||
**Return** (don't throw) from form actions to show validation errors:
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
|
||||
// Validation failed - return fail()
|
||||
if (!email || !email.includes('@')) {
|
||||
return fail(400, {
|
||||
email,
|
||||
error: 'Invalid email',
|
||||
missing: !email,
|
||||
});
|
||||
}
|
||||
|
||||
// Success - process and maybe redirect
|
||||
await processEmail(email);
|
||||
throw redirect(303, '/success');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let form; // Contains fail() return value
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? ''} />
|
||||
|
||||
{#if form?.missing}
|
||||
<span>Email is required</span>
|
||||
{/if}
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **Return** fail() (don't throw)
|
||||
- Status code (400 = bad request)
|
||||
- Return validation errors + form data to repopulate fields
|
||||
- Accessible via `form` prop in page component
|
||||
- Page stays on same URL
|
||||
|
||||
## redirect() - Navigation
|
||||
|
||||
**Throw** redirect() to navigate user to another page:
|
||||
|
||||
```typescript
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
login: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const user = await authenticate(data);
|
||||
|
||||
if (!user) {
|
||||
return fail(401, { error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
cookies.set('session', user.sessionToken, { path: '/' });
|
||||
|
||||
// Success - redirect to dashboard
|
||||
throw redirect(303, '/dashboard'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Status codes:**
|
||||
|
||||
- `303` - See Other (recommended for POST → GET redirect)
|
||||
- `301` - Moved Permanently
|
||||
- `302` - Found (temporary redirect)
|
||||
- `307` - Temporary Redirect (preserves method)
|
||||
- `308` - Permanent Redirect (preserves method)
|
||||
|
||||
**Use 303** for most cases (especially after form submission).
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **MUST throw** (not return)
|
||||
- Use status 303 for form actions
|
||||
- Can redirect to external URLs
|
||||
- Can use relative paths: `throw redirect(303, '..')`
|
||||
|
||||
## error() - Fatal Errors
|
||||
|
||||
**Throw** error() for unrecoverable errors (auth, not found, server
|
||||
error):
|
||||
|
||||
```typescript
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params, locals }) => {
|
||||
const post = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, params.id),
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found'); // MUST throw
|
||||
}
|
||||
|
||||
if (post.authorId !== locals.userId) {
|
||||
throw error(403, 'Forbidden'); // MUST throw
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
**Common status codes:**
|
||||
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized (not logged in)
|
||||
- `403` - Forbidden (logged in but no permission)
|
||||
- `404` - Not Found
|
||||
- `500` - Internal Server Error
|
||||
|
||||
**With custom error page:**
|
||||
|
||||
```typescript
|
||||
// src/routes/posts/[id]/+page.server.ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, {
|
||||
message: 'Post not found',
|
||||
postId: params.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/posts/[id]/+error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}: {$page.error.message}</h1>
|
||||
|
||||
{#if $page.error.postId}
|
||||
<p>Could not find post with ID: {$page.error.postId}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- **MUST throw** (not return)
|
||||
- Renders closest `+error.svelte` file
|
||||
- Accessible via `$page.status` and `$page.error`
|
||||
- Stops load function execution
|
||||
- Use for authorization, not found, server errors
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Not Throwing redirect()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
redirect(303, '/home'); // DOESN'T WORK
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw redirect(303, '/home'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Not Throwing error()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const load = async () => {
|
||||
error(404, 'Not found'); // DOESN'T WORK
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const load = async () => {
|
||||
throw error(404, 'Not found'); // MUST throw
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Throwing fail()
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw fail(400, { error: 'Bad' }); // Don't throw
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return fail(400, { error: 'Bad' }); // Return
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Catching redirect Without Rethrowing
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
console.error(e); // Catches redirect - it won't work!
|
||||
return fail(500, { error: 'Failed' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
import { isRedirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e; // Rethrow redirect
|
||||
console.error(e);
|
||||
return fail(500, { error: 'Failed' });
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Problem in form action?
|
||||
├─ Validation error (show to user) → return fail(400, { errors })
|
||||
├─ Success (navigate) → throw redirect(303, '/success')
|
||||
└─ Fatal error (auth, not found) → throw error(403, 'Forbidden')
|
||||
|
||||
Problem in load function?
|
||||
├─ Data not found → throw error(404, 'Not found')
|
||||
├─ Unauthorized → throw error(401, 'Unauthorized')
|
||||
├─ Forbidden → throw error(403, 'Forbidden')
|
||||
└─ Server error → throw error(500, 'Server error')
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: Validate, Process, Redirect
|
||||
|
||||
```typescript
|
||||
export const actions = {
|
||||
create: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
// 1. Validate
|
||||
if (!isValid(data)) {
|
||||
return fail(400, { errors: getErrors(data) });
|
||||
}
|
||||
|
||||
// 2. Process
|
||||
const post = await createPost(data);
|
||||
|
||||
// 3. Redirect
|
||||
throw redirect(303, `/posts/${post.id}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Check Auth, Load Data
|
||||
|
||||
```typescript
|
||||
export const load = async ({ locals, params }) => {
|
||||
// 1. Check auth
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Please log in');
|
||||
}
|
||||
|
||||
// 2. Load data
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found');
|
||||
}
|
||||
|
||||
// 3. Check permission
|
||||
if (post.authorId !== locals.user.id) {
|
||||
throw error(403, 'Not your post');
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Conditional Redirect
|
||||
|
||||
```typescript
|
||||
export const load = async ({ locals }) => {
|
||||
// Redirect if already logged in
|
||||
if (locals.user) {
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
return {}; // Show login page
|
||||
};
|
||||
```
|
||||
|
||||
## Summary Table
|
||||
|
||||
| | fail() | redirect() | error() |
|
||||
| -------------------- | ----------------- | --------------- | --------------- |
|
||||
| **Throw or return?** | Return | **Throw** | **Throw** |
|
||||
| **Use in** | Form actions | Actions & load | Actions & load |
|
||||
| **Purpose** | Validation errors | Navigate | Fatal errors |
|
||||
| **Status codes** | 400-499 | 301-308 | 400-599 |
|
||||
| **Accessible via** | `form` prop | N/A (navigates) | `+error.svelte` |
|
||||
| **Stays on page?** | Yes | No (navigates) | No (error page) |
|
||||
| **Example** | Invalid email | After save | Not found |
|
||||
|
||||
## Client-Side Navigation After Auth
|
||||
|
||||
When using client-side auth (e.g., Better Auth client), **always use `goto()` with `invalidateAll: true`** to ensure layout data refreshes:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth_client } from '$lib/auth-client';
|
||||
|
||||
async function handle_signin() {
|
||||
const result = await auth_client.signIn.email({ email, password });
|
||||
|
||||
if (!result.error) {
|
||||
// ✅ CORRECT: invalidateAll ensures layout load functions re-run
|
||||
await goto('/dashboard', { invalidateAll: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Common Mistake: Separate invalidateAll + goto
|
||||
|
||||
```typescript
|
||||
// WRONG: Data might not refresh before navigation
|
||||
await invalidateAll();
|
||||
goto('/dashboard');
|
||||
|
||||
// ALSO WRONG: goto doesn't wait for invalidation
|
||||
await invalidateAll();
|
||||
await goto('/dashboard');
|
||||
```
|
||||
|
||||
### ✅ Correct Pattern
|
||||
|
||||
```typescript
|
||||
// RIGHT: Single call with invalidateAll option
|
||||
await goto('/dashboard', { invalidateAll: true });
|
||||
```
|
||||
|
||||
**Why this matters:** After client-side auth, cookies are set but the root layout's `load` function (which typically checks `auth.api.getSession()`) has cached data. Using `invalidateAll: true` forces all load functions to re-run with the new session cookie.
|
||||
|
||||
**Note:** Server-side redirects with `throw redirect()` don't have this problem because they trigger a full page load.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
- ✅ Using `return fail()` for validation errors?
|
||||
- ✅ Using `throw redirect()` (not return) after success?
|
||||
- ✅ Using `throw error()` (not return) for fatal errors?
|
||||
- ✅ Not catching redirects/errors without rethrowing?
|
||||
- ✅ Using status code 303 for POST redirects?
|
||||
- ✅ Using `goto(url, { invalidateAll: true })` after client-side auth?
|
||||
@@ -0,0 +1,316 @@
|
||||
# Form Actions
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
Form actions live in `+page.server.ts` and handle form submissions:
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email');
|
||||
const password = data.get('password');
|
||||
|
||||
if (!email) {
|
||||
return fail(400, { email, missing: true });
|
||||
}
|
||||
|
||||
await login(email, password);
|
||||
throw redirect(303, '/dashboard');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let form; // Contains return value from action
|
||||
</script>
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? ''} />
|
||||
{#if form?.missing}
|
||||
<p class="error">Email is required</p>
|
||||
{/if}
|
||||
|
||||
<button>Login</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Named Actions
|
||||
|
||||
```typescript
|
||||
export const actions: Actions = {
|
||||
login: async ({ request }) => {
|
||||
// Handle login
|
||||
},
|
||||
|
||||
register: async ({ request }) => {
|
||||
// Handle registration
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<form method="POST" action="?/login">...</form>
|
||||
<form method="POST" action="?/register">...</form>
|
||||
```
|
||||
|
||||
## Return Values
|
||||
|
||||
### Option 1: fail() - Show Error to User
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
if (!isValid(data)) {
|
||||
return fail(400, {
|
||||
error: 'Invalid data',
|
||||
fields: Object.fromEntries(data), // Preserve input
|
||||
});
|
||||
}
|
||||
|
||||
// Success - no return needed
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option 2: redirect() - Navigate After Success
|
||||
|
||||
```typescript
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
await processForm(request);
|
||||
throw redirect(303, '/success'); // MUST throw
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option 3: error() - Fatal Error
|
||||
|
||||
```typescript
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
throw error(401, 'Unauthorized'); // MUST throw
|
||||
}
|
||||
|
||||
// Process...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Progressive Enhancement
|
||||
|
||||
Form works without JavaScript:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
</script>
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<!-- Works with or without JS -->
|
||||
</form>
|
||||
```
|
||||
|
||||
With custom handling:
|
||||
|
||||
```svelte
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={({ formData, cancel }) => {
|
||||
// Before submit
|
||||
formData.append('timestamp', Date.now().toString());
|
||||
|
||||
return async ({ result, update }) => {
|
||||
// After response
|
||||
if (result.type === 'success') {
|
||||
await update(); // Update form prop
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
...
|
||||
</form>
|
||||
```
|
||||
|
||||
## Validation Pattern
|
||||
|
||||
```typescript
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const rawData = {
|
||||
email: data.get('email'),
|
||||
password: data.get('password'),
|
||||
};
|
||||
|
||||
const result = schema.safeParse(rawData);
|
||||
|
||||
if (!result.success) {
|
||||
return fail(400, {
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
data: rawData,
|
||||
});
|
||||
}
|
||||
|
||||
// result.data is validated
|
||||
await createUser(result.data);
|
||||
throw redirect(303, '/welcome');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Not Throwing redirect/error
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
redirect(303, '/home'); // DOESN'T WORK - must throw
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
throw redirect(303, '/home');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Catching redirect Without Rethrowing
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
console.error(e); // Catches redirect!
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
try {
|
||||
await doSomething();
|
||||
throw redirect(303, '/success');
|
||||
} catch (e) {
|
||||
if (e instanceof Redirect) throw e; // Rethrow
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Returning non-serializable data
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return { date: new Date() }; // Date is not serializable
|
||||
},
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
export const actions = {
|
||||
default: async () => {
|
||||
return { date: new Date().toISOString() };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## File Upload
|
||||
|
||||
```typescript
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const file = data.get('file') as File;
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return fail(400, { error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
await saveFile(buffer, file.name);
|
||||
throw redirect(303, '/uploads');
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="file" name="file" required />
|
||||
<button>Upload</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Form Prop in Page
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data; // From load function
|
||||
export let form; // From action return value
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="success">Success!</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<input name="email" value={form?.email ?? data.email ?? ''} />
|
||||
{#if form?.errors?.email}
|
||||
<p>{form.errors.email}</p>
|
||||
{/if}
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. ✅ Actions must be in `+page.server.ts` (not `+page.ts`)
|
||||
2. ✅ ALWAYS throw `redirect()` and `error()` (not return)
|
||||
3. ✅ Return `fail()` for validation errors
|
||||
4. ✅ Return only serializable data
|
||||
5. ✅ Don't catch redirects/errors without rethrowing
|
||||
6. ✅ Use `enhance` for progressive enhancement
|
||||
7. ✅ Access FormData with `data.get('fieldName')`
|
||||
@@ -0,0 +1,292 @@
|
||||
# Load Functions: Server vs Universal
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Need | Use | File |
|
||||
| ----------------------------------- | -------------- | ----------------- |
|
||||
| Database access | Server load | `+page.server.ts` |
|
||||
| Secrets/env vars | Server load | `+page.server.ts` |
|
||||
| Server-only packages | Server load | `+page.server.ts` |
|
||||
| Browser APIs (window, localStorage) | Universal load | `+page.ts` |
|
||||
| Client-side fetch | Universal load | `+page.ts` |
|
||||
| Runs on both | Universal load | `+page.ts` |
|
||||
|
||||
## Server Load (+page.server.ts)
|
||||
|
||||
**When:** Need server-only resources (DB, secrets, server APIs)
|
||||
|
||||
**Runs:** Only on server (never in browser)
|
||||
|
||||
```typescript
|
||||
// src/routes/profile/+page.server.ts
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
// Access server-only resources
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, locals.userId),
|
||||
});
|
||||
|
||||
const posts = await db.query.posts.findMany({
|
||||
where: eq(posts.authorId, user.id),
|
||||
});
|
||||
|
||||
// Must return serializable data
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
posts,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Runs only on server
|
||||
- Can access `$lib/server/*` imports
|
||||
- Can use secrets from `env` safely
|
||||
- Return values must be JSON-serializable
|
||||
- Output is automatically passed to universal load
|
||||
|
||||
## Universal Load (+page.ts)
|
||||
|
||||
**When:** Need to run on both server and client, or need browser APIs
|
||||
|
||||
**Runs:** Server (during SSR) AND client (during navigation)
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+page.ts
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ data, fetch }) => {
|
||||
// `data` comes from +page.server.ts if it exists
|
||||
const { user } = data;
|
||||
|
||||
// Fetch additional data (works on both server and client)
|
||||
const response = await fetch('/api/stats');
|
||||
const stats = await response.json();
|
||||
|
||||
// Can access browser APIs (but check if in browser first)
|
||||
const theme =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('theme')
|
||||
: null;
|
||||
|
||||
return {
|
||||
user,
|
||||
stats,
|
||||
theme,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Runs on both server AND client
|
||||
- Receives server load output as `data` parameter
|
||||
- Use SvelteKit's `fetch` (automatically handles SSR)
|
||||
- Check `typeof window !== 'undefined'` for browser APIs
|
||||
- Cannot import from `$lib/server/*`
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Request → Server Load (+page.server.ts)
|
||||
↓ (returns { user })
|
||||
Universal Load (+page.ts)
|
||||
↓ (receives data: { user }, returns { user, stats })
|
||||
Page Component (+page.svelte)
|
||||
↓ (receives data: { user, stats })
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
return { serverData: 'from server' };
|
||||
};
|
||||
|
||||
// +page.ts
|
||||
export const load = async ({ data }) => {
|
||||
console.log(data.serverData); // 'from server'
|
||||
return { ...data, clientData: 'from universal' };
|
||||
};
|
||||
|
||||
// +page.svelte
|
||||
<script>
|
||||
export let data; // { serverData, clientData }
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Server + Universal
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - Fetch sensitive data
|
||||
export const load = async ({ locals }) => {
|
||||
const user = await getUser(locals.session);
|
||||
return { user };
|
||||
};
|
||||
|
||||
// +page.ts - Fetch public data
|
||||
export const load = async ({ data, fetch }) => {
|
||||
const publicPosts = await fetch('/api/posts').then((r) => r.json());
|
||||
return { ...data, publicPosts };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Conditional Universal Load
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async ({ fetch }) => {
|
||||
const serverData = await fetch('/api/data').then((r) => r.json());
|
||||
|
||||
// Only run in browser
|
||||
let clientOnlyData = null;
|
||||
if (browser) {
|
||||
clientOnlyData = localStorage.getItem('cache');
|
||||
}
|
||||
|
||||
return { serverData, clientOnlyData };
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Depends for Revalidation
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
export const load = async ({ fetch, depends }) => {
|
||||
depends('app:posts'); // Invalidate with invalidate('app:posts')
|
||||
|
||||
const posts = await fetch('/api/posts').then((r) => r.json());
|
||||
return { posts };
|
||||
};
|
||||
|
||||
// Somewhere else:
|
||||
import { invalidate } from '$app/navigation';
|
||||
invalidate('app:posts'); // Re-runs load function
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Importing Server Code in Universal Load
|
||||
|
||||
```typescript
|
||||
// +page.ts - WRONG
|
||||
import { db } from '$lib/server/database'; // ERROR - can't import server code
|
||||
|
||||
export const load = async () => {
|
||||
const users = await db.query.users.findMany(); // Won't work
|
||||
return { users };
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Move to `+page.server.ts`
|
||||
|
||||
### ❌ Returning Non-Serializable Data
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
const user = await User.findOne(); // Returns class instance
|
||||
return { user }; // ERROR - class instances aren't serializable
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Return plain objects
|
||||
|
||||
```typescript
|
||||
export const load = async () => {
|
||||
const user = await User.findOne();
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Using window/localStorage Without Check
|
||||
|
||||
```typescript
|
||||
// +page.ts - WRONG
|
||||
export const load = async () => {
|
||||
const theme = localStorage.getItem('theme'); // ERROR on server
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
**Fix:** Check for browser
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async () => {
|
||||
const theme = browser ? localStorage.getItem('theme') : 'light';
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
```typescript
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({
|
||||
locals,
|
||||
params,
|
||||
url,
|
||||
}) => {
|
||||
// TypeScript knows return type must be serializable
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ data, fetch, params }) => {
|
||||
// `data` is typed from +page.server.ts return type
|
||||
return {
|
||||
...data,
|
||||
extra: 'data',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## When to Use Which
|
||||
|
||||
**Use Server Load when:**
|
||||
|
||||
- Need database access
|
||||
- Need secrets/environment variables
|
||||
- Need server-only npm packages
|
||||
- Need to hide implementation details
|
||||
|
||||
**Use Universal Load when:**
|
||||
|
||||
- Need browser APIs (localStorage, window)
|
||||
- Need to fetch public APIs (works on both)
|
||||
- Want client-side navigation without server round-trip
|
||||
- Data is public and doesn't need server resources
|
||||
|
||||
**Use Both when:**
|
||||
|
||||
- Server load fetches sensitive data
|
||||
- Universal load fetches public data or adds client-side enhancements
|
||||
@@ -0,0 +1,317 @@
|
||||
# Serialization: What Can/Can't Be Returned
|
||||
|
||||
## The Rule
|
||||
|
||||
**Server load functions and form actions must return JSON-serializable
|
||||
data.**
|
||||
|
||||
Data travels from server → client as JSON. Non-JSON types break.
|
||||
|
||||
## ✅ Serializable (Safe)
|
||||
|
||||
| Type | Example | Notes |
|
||||
| ------------ | ------------------------- | --------------------------------- |
|
||||
| String | `"hello"` | ✅ |
|
||||
| Number | `42`, `3.14` | ✅ |
|
||||
| Boolean | `true`, `false` | ✅ |
|
||||
| null | `null` | ✅ |
|
||||
| Array | `[1, 2, 3]` | ✅ |
|
||||
| Plain Object | `{ name: 'Alice' }` | ✅ |
|
||||
| Nested | `{ user: { posts: [] } }` | ✅ If all values are serializable |
|
||||
|
||||
## ❌ NOT Serializable (Breaks)
|
||||
|
||||
| Type | Example | Why | Fix |
|
||||
| -------------- | -------------- | -------------------------- | -------------------------------------------- |
|
||||
| Date | `new Date()` | Becomes string | Use `.toISOString()` |
|
||||
| undefined | `undefined` | Removed from JSON | Use `null` |
|
||||
| Function | `() => {}` | Can't serialize | Remove or convert to data |
|
||||
| Class instance | `new User()` | Only serializes properties | Convert to plain object |
|
||||
| Map | `new Map()` | Becomes `{}` | Convert to object: `Object.fromEntries(map)` |
|
||||
| Set | `new Set()` | Becomes `{}` | Convert to array: `Array.from(set)` |
|
||||
| BigInt | `123n` | Error | Convert to string |
|
||||
| Symbol | `Symbol('id')` | Removed | Don't use |
|
||||
| RegExp | `/test/` | Becomes `{}` | Convert to string |
|
||||
| Error | `new Error()` | Loses stack | Extract message/code |
|
||||
|
||||
## Examples
|
||||
|
||||
### ❌ Wrong: Returning Date
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
createdAt: new Date(), // Serializes as string, not Date object
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let data;
|
||||
console.log(data.createdAt); // String, not Date
|
||||
console.log(data.createdAt.getTime()); // ERROR - not a Date
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Convert Date to ISO String
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const user = await db.users.findFirst();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
createdAt: user.createdAt.toISOString(), // Convert to string
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte -->
|
||||
<script>
|
||||
export let data;
|
||||
const createdAt = new Date(data.user.createdAt); // Parse back to Date
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ Wrong: Returning Class Instance
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
class User {
|
||||
constructor(
|
||||
public id: number,
|
||||
public name: string,
|
||||
) {}
|
||||
|
||||
getDisplayName() {
|
||||
return `User: ${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const load = async () => {
|
||||
const user = new User(1, 'Alice');
|
||||
return { user }; // Methods are lost during serialization
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data;
|
||||
console.log(data.user.getDisplayName()); // ERROR - method doesn't exist
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Return Plain Object
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const user = await db.users.findFirst();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
// Only plain data, no methods
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Wrong: undefined Values
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
name: 'Alice',
|
||||
email: undefined, // Removed during JSON.stringify
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
export let data;
|
||||
console.log('email' in data); // false - key is missing!
|
||||
</script>
|
||||
```
|
||||
|
||||
### ✅ Right: Use null
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
return {
|
||||
name: 'Alice',
|
||||
email: null, // Preserved
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Wrong: Map/Set
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
const tags = new Set(['svelte', 'typescript']);
|
||||
const metadata = new Map([['version', '1.0']]);
|
||||
|
||||
return {
|
||||
tags, // Becomes {}
|
||||
metadata, // Becomes {}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### ✅ Right: Convert to Array/Object
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
const tags = new Set(['svelte', 'typescript']);
|
||||
const metadata = new Map([['version', '1.0']]);
|
||||
|
||||
return {
|
||||
tags: Array.from(tags), // ['svelte', 'typescript']
|
||||
metadata: Object.fromEntries(metadata), // { version: '1.0' }
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## ORM Returns (Drizzle, Prisma)
|
||||
|
||||
Most ORMs return plain objects with Date fields:
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async () => {
|
||||
const user = await db.query.users.findFirst();
|
||||
// user = { id: 1, name: 'Alice', createdAt: Date }
|
||||
|
||||
return {
|
||||
user: {
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(), // Convert Date
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Or use a helper:
|
||||
|
||||
```typescript
|
||||
function serialize<T extends Record<string, any>>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj)); // Forces serialization
|
||||
}
|
||||
|
||||
export const load = async () => {
|
||||
const user = await db.query.users.findFirst();
|
||||
return { user: serialize(user) };
|
||||
};
|
||||
```
|
||||
|
||||
## BigInt
|
||||
|
||||
```typescript
|
||||
// +page.server.ts - WRONG
|
||||
export const load = async () => {
|
||||
return {
|
||||
userId: 123456789012345678901234567890n, // ERROR - can't serialize
|
||||
};
|
||||
};
|
||||
|
||||
// +page.server.ts - RIGHT
|
||||
export const load = async () => {
|
||||
return {
|
||||
userId: '123456789012345678901234567890', // String
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Detecting Serialization Issues
|
||||
|
||||
SvelteKit will throw an error if you try to return non-serializable
|
||||
data:
|
||||
|
||||
```
|
||||
Error: Data returned from `load` while rendering / is not serializable:
|
||||
- Cannot stringify arbitrary non-POJOs
|
||||
```
|
||||
|
||||
## Testing Serialization
|
||||
|
||||
```typescript
|
||||
const data = { user: new User() };
|
||||
|
||||
try {
|
||||
JSON.parse(JSON.stringify(data));
|
||||
console.log('✅ Serializable');
|
||||
} catch (e) {
|
||||
console.log('❌ Not serializable');
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Help
|
||||
|
||||
Use `satisfies` to catch issues at compile time:
|
||||
|
||||
```typescript
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
return {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
createdAt: new Date(), // TypeScript allows this, but it's problematic
|
||||
},
|
||||
};
|
||||
}) satisfies PageServerLoad;
|
||||
```
|
||||
|
||||
Better: Create a type that only allows primitives:
|
||||
|
||||
```typescript
|
||||
type Serializable =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Serializable }
|
||||
| Serializable[];
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const data: Serializable = {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
createdAt: new Date().toISOString(), // Must be string
|
||||
},
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
Before returning from server load or form action:
|
||||
|
||||
1. ✅ All values are string, number, boolean, null, array, or plain
|
||||
object?
|
||||
2. ✅ No Date objects? (convert to `.toISOString()`)
|
||||
3. ✅ No undefined? (use null)
|
||||
4. ✅ No class instances? (convert to plain objects)
|
||||
5. ✅ No Map/Set? (convert to object/array)
|
||||
6. ✅ No functions?
|
||||
7. ✅ No BigInt? (convert to string)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sveltekit Structure
|
||||
|
||||
SvelteKit structure guidance. Use for routing, layouts, error
|
||||
handling, and SSR. Covers file naming (+page vs +layout vs +server),
|
||||
nested layouts, error boundaries, and hydration.
|
||||
|
||||
## Structure
|
||||
|
||||
- `SKILL.md` - Main skill instructions
|
||||
- `references/` - Detailed documentation loaded as needed
|
||||
- `scripts/` - Executable code for deterministic operations
|
||||
- `assets/` - Templates, images, or other resources
|
||||
|
||||
## Usage
|
||||
|
||||
This skill is automatically discovered by Claude when relevant to the
|
||||
task.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: sveltekit-structure
|
||||
# IMPORTANT: Keep description on ONE line for Claude Code compatibility
|
||||
# prettier-ignore
|
||||
description: SvelteKit structure guidance. Use for routing, layouts, error handling, SSR, or svelte:boundary. Covers file naming, nested layouts, error boundaries, pending UI, and hydration.
|
||||
---
|
||||
|
||||
# SvelteKit Structure
|
||||
|
||||
## Quick Start
|
||||
|
||||
**File types:** `+page.svelte` (page) | `+layout.svelte` (wrapper) |
|
||||
`+error.svelte` (error boundary) | `+server.ts` (API endpoint)
|
||||
|
||||
**Routes:** `src/routes/about/+page.svelte` → `/about` |
|
||||
`src/routes/posts/[id]/+page.svelte` → `/posts/123`
|
||||
|
||||
**Layouts:** Apply to all child routes. `+layout.svelte` at any level
|
||||
wraps descendants.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (all pages)
|
||||
├── +page.svelte # Homepage /
|
||||
├── about/+page.svelte # /about
|
||||
└── dashboard/
|
||||
├── +layout.svelte # Dashboard layout (dashboard pages only)
|
||||
├── +page.svelte # /dashboard
|
||||
└── settings/+page.svelte # /dashboard/settings
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<nav><!-- Navigation --></nav>
|
||||
<main>{@render children()}</main>
|
||||
<footer><!-- Footer --></footer>
|
||||
```
|
||||
|
||||
## Reference Files
|
||||
|
||||
- [file-naming.md](references/file-naming.md) - File naming
|
||||
conventions
|
||||
- [layout-patterns.md](references/layout-patterns.md) - Nested layouts
|
||||
- [error-handling.md](references/error-handling.md) - +error.svelte
|
||||
placement
|
||||
- [svelte-boundary.md](references/svelte-boundary.md) -
|
||||
Component-level error/pending
|
||||
- [ssr-hydration.md](references/ssr-hydration.md) - SSR and
|
||||
browser-only code
|
||||
|
||||
## Notes
|
||||
|
||||
- Layouts: `{@render children()}` | Errors: +error.svelte _above_
|
||||
failing route
|
||||
- Groups: `(name)` folders don't affect URL | Client-only: check
|
||||
`browser`
|
||||
- **Last verified:** 2025-01-11
|
||||
|
||||
<!--
|
||||
PROGRESSIVE DISCLOSURE GUIDELINES:
|
||||
- Keep this file ~50 lines total (max ~150 lines)
|
||||
- Use 1-2 code blocks only (recommend 1)
|
||||
- Keep description <200 chars for Level 1 efficiency
|
||||
- Move detailed docs to references/ for Level 3 loading
|
||||
- This is Level 2 - quick reference ONLY, not a manual
|
||||
|
||||
LLM WORKFLOW (when editing this file):
|
||||
1. Write/edit SKILL.md
|
||||
2. Format (if formatter available)
|
||||
3. Run: claude-skills-cli validate <path>
|
||||
4. If multi-line description warning: run claude-skills-cli doctor <path>
|
||||
5. Validate again to confirm
|
||||
-->
|
||||
@@ -0,0 +1,274 @@
|
||||
# Error Handling
|
||||
|
||||
## Error Boundary Placement
|
||||
|
||||
**Key rule:** `+error.svelte` must be _above_ the failing route in the
|
||||
hierarchy.
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Catches errors in all routes below
|
||||
├── +page.svelte # If this errors → uses +error.svelte above
|
||||
└── admin/
|
||||
├── +error.svelte # Catches errors in admin routes
|
||||
└── +page.svelte # If this errors → uses admin/+error.svelte
|
||||
```
|
||||
|
||||
**Wrong:**
|
||||
|
||||
```
|
||||
src/routes/dashboard/
|
||||
├── +layout.svelte # If this errors...
|
||||
└── +error.svelte # This won't catch it (too low)
|
||||
```
|
||||
|
||||
**Right:**
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Catches dashboard layout errors
|
||||
└── dashboard/
|
||||
├── +layout.svelte
|
||||
└── +error.svelte # Catches dashboard page errors
|
||||
```
|
||||
|
||||
## Error Propagation
|
||||
|
||||
Errors bubble up to the nearest `+error.svelte`:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +error.svelte # Level 1 (root fallback)
|
||||
└── blog/
|
||||
├── +error.svelte # Level 2 (blog fallback)
|
||||
└── [slug]/
|
||||
├── +layout.server.ts # Error here → blog/+error.svelte
|
||||
├── +page.server.ts # Error here → blog/+error.svelte
|
||||
└── +page.svelte # Error here → blog/+error.svelte
|
||||
```
|
||||
|
||||
**If no error boundary exists at that level, it goes to the parent.**
|
||||
|
||||
## Basic Error Page
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1><p>{$page.error.message}</p>
|
||||
```
|
||||
|
||||
## Custom Error Data
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const post = await getPost(params.id);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, {
|
||||
message: 'Post not found',
|
||||
postId: params.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { post };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<h1>{$page.status}</h1>
|
||||
<p>{$page.error.message}</p>
|
||||
|
||||
{#if $page.error.postId}
|
||||
<p>Could not find post with ID: {$page.error.postId}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Status Code Specific Errors
|
||||
|
||||
```svelte
|
||||
<!-- +error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go home</a>
|
||||
{:else if $page.status === 403}
|
||||
<h1>Access Denied</h1>
|
||||
<p>You don't have permission to view this page.</p>
|
||||
{:else if $page.status === 401}
|
||||
<h1>Unauthorized</h1>
|
||||
<p>Please log in to continue.</p>
|
||||
<a href="/login">Login</a>
|
||||
{:else if $page.status >= 500}
|
||||
<h1>Server Error</h1>
|
||||
<p>Something went wrong on our end. Please try again later.</p>
|
||||
{:else}
|
||||
<h1>Error {$page.status}</h1>
|
||||
<p>{$page.error.message}</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Common Status Codes
|
||||
|
||||
- **400** - Bad Request (malformed request)
|
||||
- **401** - Unauthorized (not logged in)
|
||||
- **403** - Forbidden (logged in but no permission)
|
||||
- **404** - Not Found (resource doesn't exist)
|
||||
- **500** - Internal Server Error (unhandled exception)
|
||||
- **503** - Service Unavailable (server overloaded/down)
|
||||
|
||||
## Error in Layout vs Page
|
||||
|
||||
**Layout error:**
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/+layout.server.ts
|
||||
export const load = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw error(401, 'Not logged in'); // Caught by +error.svelte ABOVE dashboard
|
||||
}
|
||||
return { user: locals.user };
|
||||
};
|
||||
```
|
||||
|
||||
**Page error:**
|
||||
|
||||
```typescript
|
||||
// src/routes/dashboard/settings/+page.server.ts
|
||||
export const load = async () => {
|
||||
throw error(404, 'Settings not found'); // Caught by nearest +error.svelte
|
||||
};
|
||||
```
|
||||
|
||||
## Expected Errors vs Unexpected Errors
|
||||
|
||||
**Expected (use error()):**
|
||||
|
||||
```typescript
|
||||
// User requests invalid resource
|
||||
if (!post) throw error(404, 'Post not found');
|
||||
|
||||
// User lacks permission
|
||||
if (post.authorId !== user.id) throw error(403, 'Not your post');
|
||||
```
|
||||
|
||||
**Unexpected (let it bubble):**
|
||||
|
||||
```typescript
|
||||
// Unhandled exceptions (DB connection fails, etc.)
|
||||
// Will show generic 500 error
|
||||
const posts = await db.query.posts.findMany(); // Might throw
|
||||
```
|
||||
|
||||
## Handling Errors in handleError Hook
|
||||
|
||||
```typescript
|
||||
// src/hooks.server.ts
|
||||
import type { HandleServerError } from '@sveltejs/kit';
|
||||
|
||||
export const handleError: HandleServerError = ({ error, event }) => {
|
||||
// Log to error tracking service
|
||||
console.error('Error:', error, 'Path:', event.url.pathname);
|
||||
|
||||
// Return user-friendly message (don't expose internals)
|
||||
return {
|
||||
message: 'An unexpected error occurred',
|
||||
code: error?.code ?? 'UNKNOWN',
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Error in Load vs Error in Component
|
||||
|
||||
**Load function error (recommended):**
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async ({ params }) => {
|
||||
const user = await getUser(params.id);
|
||||
if (!user) throw error(404, 'User not found'); // Shows +error.svelte
|
||||
return { user };
|
||||
};
|
||||
```
|
||||
|
||||
**Component error (not ideal):**
|
||||
|
||||
```svelte
|
||||
<!-- +page.svelte - Don't do this -->
|
||||
<script>
|
||||
export let data;
|
||||
|
||||
// If data.user is undefined, component just shows nothing
|
||||
// No error boundary triggered
|
||||
</script>
|
||||
|
||||
<h1>{data.user.name}</h1> <!-- Might crash if user undefined -->
|
||||
```
|
||||
|
||||
**Best practice:** Validate and throw errors in `load` functions, not
|
||||
components.
|
||||
|
||||
## Fallback Error Handling
|
||||
|
||||
Always have a root `+error.svelte`:
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+error.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { dev } from '$app/environment';
|
||||
</script>
|
||||
|
||||
<h1>Oops! Something went wrong</h1>
|
||||
|
||||
{#if dev}
|
||||
<!-- Show details in dev mode -->
|
||||
<pre>{JSON.stringify($page.error, null, 2)}</pre>
|
||||
{:else}
|
||||
<!-- Generic message in production -->
|
||||
<p>We're sorry, but something unexpected happened.</p>
|
||||
{/if}
|
||||
|
||||
<a href="/">Go home</a>
|
||||
```
|
||||
|
||||
## Testing Error Boundaries
|
||||
|
||||
```typescript
|
||||
// +page.server.ts
|
||||
export const load = async ({ url }) => {
|
||||
// Test error page in dev
|
||||
if (url.searchParams.has('test-error')) {
|
||||
throw error(500, 'Test error');
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
```
|
||||
|
||||
Visit `http://localhost:5173/page?test-error` to see error boundary.
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. ✅ Place `+error.svelte` ABOVE failing routes
|
||||
2. ✅ Errors bubble up to nearest error boundary
|
||||
3. ✅ Always have a root `src/routes/+error.svelte`
|
||||
4. ✅ Throw errors in `load` functions, not components
|
||||
5. ✅ Use specific status codes (401, 403, 404, 500)
|
||||
6. ✅ Customize error pages per section (blog, admin, etc.)
|
||||
7. ✅ Use `handleError` hook for logging/monitoring
|
||||
8. ✅ Show detailed errors in dev, generic in production
|
||||
@@ -0,0 +1,69 @@
|
||||
# File Naming Conventions
|
||||
|
||||
## Core Files
|
||||
|
||||
| File | Purpose | Runs | Example |
|
||||
| ------------------- | --------------------- | --------------------- | --------------------------------------------- |
|
||||
| `+page.svelte` | Page component | Client & Server (SSR) | `/routes/about/+page.svelte` → `/about` |
|
||||
| `+page.ts` | Universal load | Client & Server | Data for +page.svelte |
|
||||
| `+page.server.ts` | Server load & actions | Server only | DB queries, form actions |
|
||||
| `+layout.svelte` | Layout wrapper | Client & Server | Wraps child routes |
|
||||
| `+layout.ts` | Layout universal load | Client & Server | Data for +layout.svelte |
|
||||
| `+layout.server.ts` | Layout server load | Server only | Auth, user data |
|
||||
| `+error.svelte` | Error boundary | Client & Server | Shown when error thrown |
|
||||
| `+server.ts` | API endpoint | Server only | `/routes/api/users/+server.ts` → `/api/users` |
|
||||
|
||||
## Route Parameters
|
||||
|
||||
| Pattern | Matches | Example |
|
||||
| -------------- | -------------- | ---------------------------------------------------------------- |
|
||||
| `[id]` | Single param | `/posts/[id]/+page.svelte` → `/posts/123` |
|
||||
| `[slug]` | Single param | `/blog/[slug]/+page.svelte` → `/blog/hello-world` |
|
||||
| `[[optional]]` | Optional param | `/search/[[query]]/+page.svelte` → `/search` or `/search/svelte` |
|
||||
| `[...rest]` | Rest params | `/docs/[...path]/+page.svelte` → `/docs/a/b/c` |
|
||||
|
||||
## Route Groups
|
||||
|
||||
| Pattern | Purpose | URL |
|
||||
| ------------- | ---------------------------- | ---------------------------------------------- |
|
||||
| `(group)` | Group routes (no URL impact) | `/(app)/dashboard/+page.svelte` → `/dashboard` |
|
||||
| `(marketing)` | Separate layouts | Different layout for marketing pages |
|
||||
|
||||
## Special Files
|
||||
|
||||
- `hooks.server.ts` - Server hooks (handle function, runs on every
|
||||
request)
|
||||
- `hooks.client.ts` - Client hooks (runs in browser)
|
||||
- `app.html` - HTML template
|
||||
- `service-worker.ts` - Service worker
|
||||
- `params/*.ts` - Param validators
|
||||
|
||||
## Common Patterns
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout
|
||||
├── +layout.ts # Root data
|
||||
├── +page.svelte # Homepage
|
||||
├── +error.svelte # Root error boundary
|
||||
│
|
||||
├── (app)/ # App routes (grouped)
|
||||
│ ├── +layout.svelte # App layout (auth required)
|
||||
│ ├── dashboard/+page.svelte # /dashboard
|
||||
│ └── settings/+page.svelte # /settings
|
||||
│
|
||||
├── (marketing)/ # Marketing routes (grouped)
|
||||
│ ├── +layout.svelte # Marketing layout
|
||||
│ ├── about/+page.svelte # /about
|
||||
│ └── pricing/+page.svelte # /pricing
|
||||
│
|
||||
├── blog/
|
||||
│ ├── +page.svelte # /blog (list)
|
||||
│ └── [slug]/
|
||||
│ ├── +page.svelte # /blog/post-title
|
||||
│ └── +page.server.ts # Load post data
|
||||
│
|
||||
└── api/
|
||||
└── posts/
|
||||
└── +server.ts # API: GET/POST /api/posts
|
||||
```
|
||||
@@ -0,0 +1,387 @@
|
||||
# Layout Patterns
|
||||
|
||||
## Basic Layout
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header>Header</header>
|
||||
<main>{@render children()}</main>
|
||||
<footer>Footer</footer>
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Must declare `children` in `$props()`
|
||||
- Use `{@render children()}` to render nested content
|
||||
- Root layout wraps ALL pages
|
||||
|
||||
## Nested Layouts
|
||||
|
||||
Layouts inherit from parent layouts:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (all pages)
|
||||
└── dashboard/
|
||||
├── +layout.svelte # Dashboard layout (dashboard pages only)
|
||||
└── +page.svelte # Uses both layouts
|
||||
```
|
||||
|
||||
**Result:** Root layout wraps dashboard layout wraps page.
|
||||
|
||||
**Rendering order:**
|
||||
|
||||
```
|
||||
Root +layout.svelte
|
||||
└─ Dashboard +layout.svelte
|
||||
└─ +page.svelte
|
||||
```
|
||||
|
||||
### Example: Nested Layout Pattern
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<nav>Global Nav</nav>
|
||||
{@render children()}
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<aside>Dashboard Sidebar</aside>
|
||||
<main>{@render children()}</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+page.svelte --><h1>Dashboard Home</h1>
|
||||
```
|
||||
|
||||
**Rendered HTML structure:**
|
||||
|
||||
```html
|
||||
<div class="app">
|
||||
<nav>Global Nav</nav>
|
||||
<div class="dashboard">
|
||||
<aside>Dashboard Sidebar</aside>
|
||||
<main>
|
||||
<h1>Dashboard Home</h1>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Layout Groups
|
||||
|
||||
Use `(groups)` to organize without affecting URLs:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── (marketing)/
|
||||
│ ├── +layout.svelte # Marketing layout
|
||||
│ ├── about/+page.svelte # /about (uses marketing layout)
|
||||
│ └── pricing/+page.svelte # /pricing (uses marketing layout)
|
||||
│
|
||||
└── (app)/
|
||||
├── +layout.svelte # App layout
|
||||
├── dashboard/+page.svelte # /dashboard (uses app layout)
|
||||
└── settings/+page.svelte # /settings (uses app layout)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Parentheses make groups invisible in URLs
|
||||
- `/about` route, NOT `/(marketing)/about`
|
||||
- Different layouts for different sections
|
||||
- Useful for authentication boundaries
|
||||
|
||||
### Example: Marketing vs App Layouts
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/(marketing)/+layout.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<header class="marketing-header">
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="/login">Login</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<footer>© 2024 Company</footer>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/(app)/+layout.svelte -->
|
||||
<script>
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<nav>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<span>Welcome, {data.user.name}</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Reset Layout
|
||||
|
||||
Use `@` in filename to break layout inheritance:
|
||||
|
||||
**NOT RECOMMENDED** - This feature is deprecated in SvelteKit 2+.
|
||||
|
||||
Instead, use layout groups to create separate hierarchies:
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (for most pages)
|
||||
├── (app)/
|
||||
│ └── +layout.svelte # App layout
|
||||
└── (public)/
|
||||
└── +layout.svelte # Public layout (completely separate)
|
||||
```
|
||||
|
||||
## Layout with Data Loading
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.server.ts -->
|
||||
export const load = async ({ locals }) => {
|
||||
// Available to all child routes
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
let { children, data } = $props();
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if data.user}
|
||||
<span>Welcome, {data.user.name}</span>
|
||||
{:else}
|
||||
<a href="/login">Login</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Conditional Layout Content
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if !$page.url.pathname.startsWith('/admin')}
|
||||
<header>Public Header</header>
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
|
||||
{#if !$page.url.pathname.includes('/checkout')}
|
||||
<footer>Footer</footer>
|
||||
{/if}
|
||||
```
|
||||
|
||||
**Better approach:** Use layout groups instead of conditionals.
|
||||
|
||||
## Protected Layouts
|
||||
|
||||
```typescript
|
||||
// src/routes/(app)/+layout.server.ts
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
All routes under `(app)` group now require authentication.
|
||||
|
||||
## Sharing Layout State
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { setContext } from 'svelte';
|
||||
let { children, data } = $props();
|
||||
|
||||
// Share state with all descendant components
|
||||
setContext('user', data.user);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- Any child component -->
|
||||
<script>
|
||||
import { getContext } from 'svelte';
|
||||
const user = getContext('user');
|
||||
</script>
|
||||
|
||||
<p>Hello, {user.name}</p>
|
||||
```
|
||||
|
||||
## Layout Slot Props (Snippets)
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+layout.svelte -->
|
||||
<script>
|
||||
let { children, header } = $props();
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<aside>Sidebar</aside>
|
||||
<div class="content">
|
||||
{#if header}
|
||||
<header>{@render header()}</header>
|
||||
{/if}
|
||||
<main>{@render children()}</main>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/dashboard/+page.svelte -->
|
||||
{#snippet header()}
|
||||
<h1>Custom Dashboard Header</h1>
|
||||
{/snippet}
|
||||
|
||||
<p>Dashboard content</p>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Auth Boundary
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root (no auth)
|
||||
├── (public)/
|
||||
│ ├── +layout.svelte # Public layout (landing pages)
|
||||
│ ├── +page.svelte # /
|
||||
│ └── about/+page.svelte # /about
|
||||
└── (protected)/
|
||||
├── +layout.server.ts # Check auth, redirect if not logged in
|
||||
├── +layout.svelte # Protected layout (app UI)
|
||||
├── dashboard/+page.svelte # /dashboard
|
||||
└── settings/+page.svelte # /settings
|
||||
```
|
||||
|
||||
### Pattern 2: Multi-tenant with Shared Root
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── +layout.svelte # Root layout (shared)
|
||||
├── admin/
|
||||
│ ├── +layout.svelte # Admin layout
|
||||
│ └── users/+page.svelte # /admin/users
|
||||
└── client/
|
||||
├── +layout.svelte # Client layout
|
||||
└── projects/+page.svelte # /client/projects
|
||||
```
|
||||
|
||||
### Pattern 3: Progressive Enhancement
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { navigating } from '$app/stores';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
{#if $navigating}
|
||||
<div class="loading-bar"></div>
|
||||
{/if}
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: blue;
|
||||
animation: loading 1s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Layout Best Practices
|
||||
|
||||
1. ✅ Keep root layout minimal (shared across ALL pages)
|
||||
2. ✅ Use layout groups for separate sections
|
||||
3. ✅ Load shared data in layout's load function
|
||||
4. ✅ Use context for sharing state with descendants
|
||||
5. ✅ Avoid too many nested layouts (max 2-3 levels)
|
||||
6. ✅ Don't put auth logic in root layout (use groups)
|
||||
7. ✅ Remember: layouts share data DOWN, not UP
|
||||
8. ❌ Avoid conditionals in layouts (use groups instead)
|
||||
|
||||
## Debugging Layouts
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
let { children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
console.log('Current route:', $page.url.pathname);
|
||||
console.log('Layout data:', $page.data);
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
```
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- Layouts wrap all child routes
|
||||
- Nested layouts create hierarchy (root → section → page)
|
||||
- Groups `(name)` organize without affecting URLs
|
||||
- Load data in `+layout.server.ts` to share with children
|
||||
- Use context to share reactive state
|
||||
- Keep layouts simple and focused
|
||||
@@ -0,0 +1,92 @@
|
||||
# SSR & Hydration
|
||||
|
||||
## The Problem
|
||||
|
||||
SvelteKit runs on server (SSR) then hydrates in browser. Code using
|
||||
browser APIs (`window`, `document`, `localStorage`) fails on server.
|
||||
|
||||
## Solution: Check for Browser
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// In load function
|
||||
export const load = async () => {
|
||||
const theme = browser ? localStorage.getItem('theme') : 'light';
|
||||
return { theme };
|
||||
};
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- In component -->
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let data = $state(null);
|
||||
|
||||
// Option 1: Use browser check
|
||||
if (browser) {
|
||||
data = localStorage.getItem('data');
|
||||
}
|
||||
|
||||
// Option 2: Use onMount (only runs in browser)
|
||||
onMount(() => {
|
||||
data = localStorage.getItem('data');
|
||||
});
|
||||
|
||||
// Option 3: Use $effect with browser check
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
data = localStorage.getItem('data');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Using window Without Check
|
||||
|
||||
```typescript
|
||||
// WRONG - fails on server
|
||||
export const load = async () => {
|
||||
const width = window.innerWidth; // ERROR on server
|
||||
return { width };
|
||||
};
|
||||
|
||||
// RIGHT
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async () => {
|
||||
const width = browser ? window.innerWidth : 1024;
|
||||
return { width };
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ Accessing DOM in Load
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export const load = async () => {
|
||||
const el = document.getElementById('root'); // ERROR on server
|
||||
};
|
||||
|
||||
// RIGHT - Do DOM stuff in onMount
|
||||
export const load = async () => {
|
||||
return {};
|
||||
};
|
||||
// Then in component:
|
||||
onMount(() => {
|
||||
const el = document.getElementById('root');
|
||||
});
|
||||
```
|
||||
|
||||
## Disable SSR (Not Recommended)
|
||||
|
||||
```typescript
|
||||
// +page.ts
|
||||
export const ssr = false; // Disables SSR for this page
|
||||
```
|
||||
|
||||
Only use when absolutely necessary (e.g., heavy Canvas/WebGL).
|
||||
@@ -0,0 +1,150 @@
|
||||
# svelte:boundary Component
|
||||
|
||||
> Available in Svelte 5.3+
|
||||
|
||||
## Two Purposes
|
||||
|
||||
1. **Error boundaries** - catch rendering errors
|
||||
2. **Pending UI** - show loading state while `await` resolves
|
||||
|
||||
## Basic Error Boundary
|
||||
|
||||
```svelte
|
||||
<svelte:boundary onerror={(e, reset) => console.error(e)}>
|
||||
<RiskyComponent />
|
||||
|
||||
{#snippet failed(error, reset)}
|
||||
<p>Error: {error.message}</p>
|
||||
<button onclick={reset}>Try again</button>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Pending UI (Loading States)
|
||||
|
||||
> **⚠️ Known Bug:** `<svelte:boundary>` + `{@const await}` causes
|
||||
> infinite navigation loops during client-side page transitions when
|
||||
> pages share async queries. See
|
||||
> [sveltejs/svelte#17717](https://github.com/sveltejs/svelte/issues/17717).
|
||||
> Use `{#await}` blocks until this is fixed.
|
||||
|
||||
With `experimental.async: true` (Svelte 5.36+), `{@const await}` is
|
||||
possible but **not safe for pages with shared queries or navigation**:
|
||||
|
||||
```svelte
|
||||
<!-- ⚠️ Causes navigation loops - use {#await} instead -->
|
||||
<svelte:boundary>
|
||||
{#snippet pending()}
|
||||
<LoadingSpinner />
|
||||
{/snippet}
|
||||
|
||||
{@const data = await loadData()}
|
||||
<DataView {data} />
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
**Safe alternative:**
|
||||
|
||||
```svelte
|
||||
{#await loadData()}
|
||||
<LoadingSpinner />
|
||||
{:then data}
|
||||
<DataView {data} />
|
||||
{:catch error}
|
||||
<p>Error: {error.message}</p>
|
||||
{/await}
|
||||
```
|
||||
|
||||
## Combined Error + Pending
|
||||
|
||||
Use `svelte:boundary` for **error catching only**, with `{#await}` for
|
||||
async data:
|
||||
|
||||
```svelte
|
||||
<svelte:boundary onerror={logError}>
|
||||
{#snippet failed(error, reset)}
|
||||
<p>Failed to load user</p>
|
||||
<button onclick={reset}>Retry</button>
|
||||
{/snippet}
|
||||
|
||||
{#await fetchUser()}
|
||||
<p>Loading user...</p>
|
||||
{:then user}
|
||||
<UserProfile {user} />
|
||||
{:catch error}
|
||||
<p>Error: {error.message}</p>
|
||||
{/await}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## What Gets Caught
|
||||
|
||||
**Caught:**
|
||||
|
||||
- Errors during rendering
|
||||
- Errors in `$effect`
|
||||
|
||||
**NOT Caught:**
|
||||
|
||||
- Event handler errors (`onclick`, etc.)
|
||||
- Errors after `setTimeout`
|
||||
- Async errors outside boundary's await
|
||||
|
||||
## vs +error.svelte
|
||||
|
||||
| Feature | svelte:boundary | +error.svelte |
|
||||
| -------- | ----------------------- | ------------- |
|
||||
| Scope | Component subtree | Route segment |
|
||||
| Reset | Built-in reset function | Navigate away |
|
||||
| Pending | Yes (pending snippet) | No |
|
||||
| Use case | Component-level | Page-level |
|
||||
|
||||
## Error Tracking Integration
|
||||
|
||||
```svelte
|
||||
<svelte:boundary
|
||||
onerror={(error, reset) => {
|
||||
// Send to Sentry, LogRocket, etc.
|
||||
errorTracker.captureException(error);
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
|
||||
{#snippet failed(error, reset)}
|
||||
<ErrorFallback {error} {reset} />
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Nested Boundaries
|
||||
|
||||
Inner boundary catches first:
|
||||
|
||||
```svelte
|
||||
<svelte:boundary>
|
||||
<!-- Outer fallback -->
|
||||
{#snippet failed(e)}
|
||||
<p>Outer caught: {e.message}</p>
|
||||
{/snippet}
|
||||
|
||||
<svelte:boundary>
|
||||
<!-- Inner fallback -->
|
||||
{#snippet failed(e)}
|
||||
<p>Inner caught: {e.message}</p>
|
||||
{/snippet}
|
||||
|
||||
<ComponentThatMightFail />
|
||||
</svelte:boundary>
|
||||
</svelte:boundary>
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Use `svelte:boundary` for component-level error isolation
|
||||
- Use `+error.svelte` for route-level error pages
|
||||
- `pending` snippet shows only on **initial** load (not on refresh - no flicker)
|
||||
- `failed` snippet replaces content on error
|
||||
- `reset` function lets users retry
|
||||
- Errors in event handlers are NOT caught
|
||||
- Requires `experimental.async: true` in svelte.config.js for `{@const await}`
|
||||
- **⚠️ Bug:** `{@const await}` + navigation causes infinite loops ([#17717](https://github.com/sveltejs/svelte/issues/17717)) — use `{#await}` instead
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
name: typescript-best-practices
|
||||
description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files.
|
||||
---
|
||||
|
||||
# TypeScript Best Practices
|
||||
|
||||
## Pair with React Best Practices
|
||||
|
||||
When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill.
|
||||
|
||||
## Type-First Development
|
||||
|
||||
Types define the contract before implementation. Follow this workflow:
|
||||
|
||||
1. **Define the data model** - types, interfaces, and schemas first
|
||||
2. **Define function signatures** - input/output types before logic
|
||||
3. **Implement to satisfy types** - let the compiler guide completeness
|
||||
4. **Validate at boundaries** - runtime checks where data enters the system
|
||||
|
||||
### Make Illegal States Unrepresentable
|
||||
|
||||
Use the type system to prevent invalid states at compile time.
|
||||
|
||||
**Discriminated unions for mutually exclusive states:**
|
||||
```ts
|
||||
// Good: only valid combinations possible
|
||||
type RequestState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error };
|
||||
|
||||
// Bad: allows invalid combinations like { loading: true, error: Error }
|
||||
type RequestState<T> = {
|
||||
loading: boolean;
|
||||
data?: T;
|
||||
error?: Error;
|
||||
};
|
||||
```
|
||||
|
||||
**Branded types for domain primitives:**
|
||||
```ts
|
||||
type UserId = string & { readonly __brand: 'UserId' };
|
||||
type OrderId = string & { readonly __brand: 'OrderId' };
|
||||
|
||||
// Compiler prevents passing OrderId where UserId expected
|
||||
function getUser(id: UserId): Promise<User> { /* ... */ }
|
||||
|
||||
function createUserId(id: string): UserId {
|
||||
return id as UserId;
|
||||
}
|
||||
```
|
||||
|
||||
**Const assertions for literal unions:**
|
||||
```ts
|
||||
const ROLES = ['admin', 'user', 'guest'] as const;
|
||||
type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest'
|
||||
|
||||
// Array and type stay in sync automatically
|
||||
function isValidRole(role: string): role is Role {
|
||||
return ROLES.includes(role as Role);
|
||||
}
|
||||
```
|
||||
|
||||
**Required vs optional fields - be explicit:**
|
||||
```ts
|
||||
// Creation: some fields required
|
||||
type CreateUser = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Update: all fields optional
|
||||
type UpdateUser = Partial<CreateUser>;
|
||||
|
||||
// Database row: all fields present
|
||||
type User = CreateUser & {
|
||||
id: UserId;
|
||||
createdAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type.
|
||||
|
||||
## Functional Patterns
|
||||
|
||||
- Prefer `const` over `let`; use `readonly` and `Readonly<T>` for immutable data.
|
||||
- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines.
|
||||
- Write pure functions for business logic; isolate side effects in dedicated modules.
|
||||
- Avoid mutating function parameters; return new objects/arrays instead.
|
||||
|
||||
## Instructions
|
||||
|
||||
- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time.
|
||||
- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors.
|
||||
- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging.
|
||||
- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises.
|
||||
- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes.
|
||||
- Add or update focused tests when changing logic; test behavior, not implementation details.
|
||||
|
||||
## Examples
|
||||
|
||||
Explicit failure for unimplemented logic:
|
||||
```ts
|
||||
export function buildWidget(widgetType: string): never {
|
||||
throw new Error(`buildWidget not implemented for type: ${widgetType}`);
|
||||
}
|
||||
```
|
||||
|
||||
Exhaustive switch with never check:
|
||||
```ts
|
||||
type Status = "active" | "inactive";
|
||||
|
||||
export function processStatus(status: Status): string {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "processing";
|
||||
case "inactive":
|
||||
return "skipped";
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
throw new Error(`unhandled status: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wrap external calls with context:
|
||||
```ts
|
||||
export async function fetchWidget(id: string): Promise<Widget> {
|
||||
const response = await fetch(`/api/widgets/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch widget ${id} failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
```
|
||||
|
||||
Debug logging with namespaced logger:
|
||||
```ts
|
||||
import debug from "debug";
|
||||
|
||||
const log = debug("myapp:widgets");
|
||||
|
||||
export function createWidget(name: string): Widget {
|
||||
log("creating widget: %s", name);
|
||||
const widget = { id: crypto.randomUUID(), name };
|
||||
log("created widget: %s", widget.id);
|
||||
return widget;
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime Validation with Zod
|
||||
|
||||
- Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas.
|
||||
- Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug.
|
||||
- Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions.
|
||||
- Add `.transform()` for data normalization at parse time (trim strings, parse dates).
|
||||
- Include descriptive error messages; use `.refine()` for custom validation logic.
|
||||
|
||||
### Examples
|
||||
|
||||
Schema as source of truth with type inference:
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
|
||||
const UserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.string().transform((s) => new Date(s)),
|
||||
});
|
||||
|
||||
type User = z.infer<typeof UserSchema>;
|
||||
```
|
||||
|
||||
Return parse results to callers (never swallow errors):
|
||||
```ts
|
||||
import { z, SafeParseReturnType } from "zod";
|
||||
|
||||
export function parseUserInput(raw: unknown): SafeParseReturnType<unknown, User> {
|
||||
return UserSchema.safeParse(raw);
|
||||
}
|
||||
|
||||
// Caller handles both success and error:
|
||||
const result = parseUserInput(formData);
|
||||
if (!result.success) {
|
||||
setErrors(result.error.flatten().fieldErrors);
|
||||
return;
|
||||
}
|
||||
await submitUser(result.data);
|
||||
```
|
||||
|
||||
Strict parsing at trust boundaries:
|
||||
```ts
|
||||
export async function fetchUser(id: string): Promise<User> {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch user ${id} failed: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return UserSchema.parse(data); // throws if API contract violated
|
||||
}
|
||||
```
|
||||
|
||||
Schema composition:
|
||||
```ts
|
||||
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
|
||||
const UpdateUserSchema = CreateUserSchema.partial();
|
||||
const UserWithPostsSchema = UserSchema.extend({
|
||||
posts: z.array(PostSchema),
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately.
|
||||
- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase.
|
||||
- Use sensible defaults for development; require explicit values for production secrets.
|
||||
|
||||
### Examples
|
||||
|
||||
Typed config with Zod validation:
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
PORT: z.coerce.number().default(3000),
|
||||
DATABASE_URL: z.string().url(),
|
||||
API_KEY: z.string().min(1),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
});
|
||||
|
||||
export const config = ConfigSchema.parse(process.env);
|
||||
```
|
||||
|
||||
Access config values (not process.env directly):
|
||||
```ts
|
||||
import { config } from "./config";
|
||||
|
||||
const server = app.listen(config.PORT);
|
||||
const db = connect(config.DATABASE_URL);
|
||||
```
|
||||
|
||||
## Optional: type-fest
|
||||
|
||||
For advanced type utilities beyond TypeScript builtins, consider [type-fest](https://github.com/sindresorhus/type-fest):
|
||||
|
||||
- `Opaque<T, Token>` - cleaner branded types than manual `& { __brand }` pattern
|
||||
- `PartialDeep<T>` - recursive partial for nested objects
|
||||
- `ReadonlyDeep<T>` - recursive readonly for immutable data
|
||||
- `LiteralUnion<Literals, Fallback>` - literals with autocomplete + string fallback
|
||||
- `SetRequired<T, K>` / `SetOptional<T, K>` - targeted field modifications
|
||||
- `Simplify<T>` - flatten complex intersection types in IDE tooltips
|
||||
|
||||
```ts
|
||||
import type { Opaque, PartialDeep, SetRequired } from 'type-fest';
|
||||
|
||||
// Branded type (cleaner than manual approach)
|
||||
type UserId = Opaque<string, 'UserId'>;
|
||||
|
||||
// Deep partial for patch operations
|
||||
type UserPatch = PartialDeep<User>;
|
||||
|
||||
// Make specific fields required
|
||||
type UserWithEmail = SetRequired<Partial<User>, 'email'>;
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
data
|
||||
*.db
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything in the repo
|
||||
* @fredicious
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Something isn't working as expected
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear summary of the bug.
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What should happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What happens instead.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Crumbs version**: (e.g. 0.3.2)
|
||||
- **Browser**: (e.g. Chrome 120, Firefox 121)
|
||||
- **OS**: (e.g. macOS 15, Ubuntu 24.04)
|
||||
- **Deployment**: (Docker / Node.js / dev server)
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain the problem.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other relevant information (logs, related issues, etc.).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea or improvement
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear description of the feature you'd like to see.
|
||||
|
||||
## Motivation
|
||||
|
||||
Why is this feature important? What problem does it solve?
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
How do you think this should work?
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Have you considered other approaches? If so, why were they ruled out?
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any mockups, links, or references that help illustrate the request.
|
||||
@@ -0,0 +1,18 @@
|
||||
## Summary
|
||||
|
||||
What does this PR do and why?
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] E2E tests added/updated (if user-facing)
|
||||
- [ ] `make check` passes
|
||||
- [ ] `make test` passes
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #
|
||||
|
||||
## Screenshots
|
||||
|
||||
If this changes the UI, include before/after screenshots.
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Bump & Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Override bump type (leave empty to auto-detect)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run release script
|
||||
env:
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump }}
|
||||
run: bash scripts/release.sh "$BUMP_TYPE"
|
||||
|
||||
- name: Output new tag
|
||||
id: release-tag
|
||||
run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ci:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump.outputs.tag }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.bump.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ needs.bump.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -54,3 +54,28 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['website/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -31,3 +31,9 @@ vite.config.ts.timestamp-*
|
||||
# Test results
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Local notes
|
||||
todo.md
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.2] - 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- Update docker-compose only after Docker image is pushed
|
||||
|
||||
## [0.3.1] - 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- Include tags in sync pull response and update API spec
|
||||
|
||||
### Changed
|
||||
- Update FEATURES.md, DEPLOYMENT.md, and CLAUDE.md documentation
|
||||
|
||||
## [0.3.0] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- Tag management system with auto-extraction from note content
|
||||
- Full-text search across titles, content, and tags
|
||||
- Dark mode with system preference detection
|
||||
- Note pinning, archive, and trash workflows
|
||||
- Image attachments with drag-and-drop upload
|
||||
- 12 color themes for note cards
|
||||
- PWA support with offline-first IndexedDB sync
|
||||
- LWW CRDT-based conflict resolution
|
||||
- Docker deployment with multi-stage build
|
||||
- CI/CD pipeline with GitHub Actions
|
||||
- Comprehensive E2E test suite with Playwright
|
||||
- OpenAPI spec and auto-generated API docs
|
||||
|
||||
### Changed
|
||||
- Initial public release
|
||||
|
||||
[0.3.2]: https://github.com/bretzel-app/crumbs/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/bretzel-app/crumbs/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/bretzel-app/crumbs/releases/tag/v0.3.0
|
||||
@@ -14,21 +14,73 @@ Crumbs by Bretzel — self-hostable note-taking app. Single-user, offline-first
|
||||
- **PWA**: `@vite-pwa/sveltekit` for service worker and offline caching
|
||||
- **Package manager**: pnpm
|
||||
|
||||
## Design system: Retro Parchment
|
||||
|
||||
The UI follows a **parchment + 8-bit retro** aesthetic. All design decisions must preserve this character.
|
||||
|
||||
### Color palette
|
||||
|
||||
All colors are CSS variables in `src/app.css`. Never use hardcoded Tailwind colors (e.g. `text-gray-500`, `bg-red-600`) — always reference CSS variables:
|
||||
|
||||
| Variable | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--bg-base` | `#f0e6d3` | Page background (warm parchment) |
|
||||
| `--bg-surface` | `#faf5eb` | Cards, panels, inputs |
|
||||
| `--primary` | `#C8860A` | Brand gold — links, active states, accents |
|
||||
| `--primary-hover` | `#E8A020` | Gold hover state |
|
||||
| `--text` | `#1a1a2e` | Primary text (near-black) |
|
||||
| `--text-muted` | `#6b6272` | Secondary text, placeholders |
|
||||
| `--border` | `#1a1a2e` | Strong borders (editor, login card) |
|
||||
| `--border-subtle` | `#d4cabb` | Default card borders, dividers |
|
||||
| `--destructive` | `#a63d2f` | Delete actions, error icons |
|
||||
| `--error-bg/border/text` | warm reds | Error states (themed, not clinical red) |
|
||||
| `--success-bg/text` | warm greens | Success states |
|
||||
| `--card-shadow` | `2px 2px 0px` | Hard-offset retro shadow (no blur) |
|
||||
| `--card-shadow-hover` | `3px 3px 0px` | Hover shadow in primary gold |
|
||||
|
||||
Note colors for cards are defined in `src/lib/utils/colors.ts`.
|
||||
|
||||
### Typography
|
||||
|
||||
- **Body**: `JetBrains Mono` (monospace) — the hacker/dev aesthetic
|
||||
- **Brand / empty states**: `Press Start 2P` (pixel font) — the 8-bit accent
|
||||
- These two fonts are intentional. Do not replace them or add others.
|
||||
|
||||
### Visual rules
|
||||
|
||||
- **Shadows**: Always hard-offset (`2px 2px 0px`) with zero blur. Never use `shadow-sm`, `shadow-md` etc. — they look too modern.
|
||||
- **Corners**: `rounded-sm` everywhere. Sharp, not rounded. Exception: color picker circles use `rounded-full`.
|
||||
- **Borders**: Default card borders use `--border-subtle`. `--border` is for strong emphasis (editor, login card). Hover borders use `--primary`.
|
||||
- **Animations**: Crisp and fast (150ms `ease-out`). No spring physics, no bounce. The retro feel comes from snappy transitions.
|
||||
- **Background texture**: A subtle 4px pixel grid overlay at 3% opacity (defined in `body::before`).
|
||||
- **Checkboxes**: Use `accent-color: var(--primary)` globally — gold checkboxes match the theme.
|
||||
- **Icons**: Lucide icons throughout. Keep at 16-20px size.
|
||||
|
||||
### Layout groups
|
||||
|
||||
- `(auth)` — Login/setup pages: minimal centered layout, no app chrome
|
||||
- `(app)` — Main app: header, sidebar, footer, toast notifications
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
routes/ # SvelteKit pages and API routes
|
||||
+page.svelte # Main notes view
|
||||
login/ # Login page
|
||||
setup/ # First-time password setup
|
||||
routes/
|
||||
+layout.svelte # Root layout (imports CSS only)
|
||||
(auth)/ # Layout group: login/setup (minimal, no app chrome)
|
||||
+layout.svelte # Centered parchment background
|
||||
login/ # Login page
|
||||
setup/ # First-time password setup
|
||||
(app)/ # Layout group: main app (header, sidebar, footer)
|
||||
+layout.svelte # App shell with Header, Sidebar, Toast
|
||||
+page.svelte # Main notes view
|
||||
api/auth/ # Login, logout, setup endpoints
|
||||
api/notes/ # CRUD + attachments
|
||||
api/search/ # Full-text search
|
||||
api/sync/ # Offline sync
|
||||
api/tags/ # Tag management
|
||||
lib/
|
||||
components/ # Svelte components (NoteCard, NoteEditor, ColorPicker, etc.)
|
||||
components/ # Svelte components (NoteCard, NoteEditor, TagChip, etc.)
|
||||
stores/ # Svelte stores (notes.ts, theme.ts)
|
||||
server/db/ # Drizzle schema + connection (schema.ts, index.ts)
|
||||
server/ # Auth logic, attachment handling
|
||||
@@ -67,6 +119,10 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to
|
||||
| `make docker-down` | — | Stop containers |
|
||||
| `make docker-logs` | — | Tail container logs |
|
||||
| `make clean` | — | Remove build artifacts + test DBs |
|
||||
| `make release` | — | Auto-bump version from commits, tag, and push |
|
||||
| `make release-patch` | — | Bump patch version, tag, and push |
|
||||
| `make release-minor` | — | Bump minor version, tag, and push |
|
||||
| `make release-major` | — | Bump major version, tag, and push |
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -79,10 +135,14 @@ GitHub Actions with two workflows:
|
||||
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
|
||||
5. **Docker Build** — validates the Docker image builds (depends on step 3)
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
|
||||
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
|
||||
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
|
||||
- Creates a GitHub Release with auto-generated notes
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
|
||||
- Same as above: CI pipeline, Docker build/push, GitHub Release
|
||||
|
||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||
|
||||
## Testing philosophy: BDD/TDD
|
||||
@@ -132,3 +192,14 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
|
||||
```
|
||||
|
||||
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
|
||||
|
||||
## Documentation
|
||||
|
||||
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| `docs/FEATURES.md` | User-facing feature list |
|
||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
Crumbs is a community project, and we want everyone to feel welcome regardless of background, identity, or experience level. We are committed to providing a harassment-free experience for all participants.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- Be respectful and considerate in communication
|
||||
- Give and accept constructive feedback gracefully
|
||||
- Focus on what is best for the project and community
|
||||
- Show empathy towards others
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
- Harassment, intimidation, or discrimination of any kind
|
||||
- Personal attacks, insults, or derogatory language
|
||||
- Publishing others' private information without consent
|
||||
- Any conduct that would be considered inappropriate in a professional setting
|
||||
|
||||
## Scope
|
||||
|
||||
This code of conduct applies to all project spaces — issues, pull requests, discussions, and any other communication channel associated with Crumbs.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of unacceptable behavior may be reported to the maintainers at **security@bretzel.app**. All reports will be reviewed and handled confidentially. Maintainers reserve the right to remove, edit, or reject contributions that violate this code of conduct, and to temporarily or permanently ban contributors for repeated or severe violations.
|
||||
|
||||
## Attribution
|
||||
|
||||
This code of conduct is inspired by the [Contributor Covenant](https://www.contributor-covenant.org/) v2.1.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Contributing to Crumbs
|
||||
|
||||
Thanks for your interest in contributing! This guide will help you get started.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/bretzel-app/crumbs.git
|
||||
cd crumbs
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start the dev server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 and set your password on first visit.
|
||||
|
||||
Run `make help` to see all available commands.
|
||||
|
||||
## Project Structure
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. Key directories:
|
||||
|
||||
- `src/routes/` — SvelteKit pages and API endpoints
|
||||
- `src/lib/components/` — Svelte 5 components (runes, not stores)
|
||||
- `src/lib/server/db/` — Drizzle ORM schema and database connection
|
||||
- `src/lib/sync/` — Offline sync and CRDT logic
|
||||
- `tests/unit/` — Vitest unit tests
|
||||
- `tests/e2e/` — Playwright end-to-end tests
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. **Fork and branch** — Create a feature branch from `main`
|
||||
2. **Follow existing patterns** — Match the code style you see in the codebase
|
||||
3. **Write tests** — Add unit tests for logic, E2E tests for user-facing features
|
||||
4. **Run checks before pushing:**
|
||||
```bash
|
||||
make check # Type checking
|
||||
make lint # Linting
|
||||
make test-unit # Unit tests
|
||||
make test-e2e # E2E tests
|
||||
```
|
||||
5. **Open a PR** — Target `main`, describe what and why
|
||||
|
||||
## Commit Messages
|
||||
|
||||
We use conventional-style commit messages:
|
||||
|
||||
```
|
||||
feat: add tag autocomplete
|
||||
fix: prevent duplicate notes on sync
|
||||
docs: update deployment guide
|
||||
chore: bump dependencies
|
||||
```
|
||||
|
||||
Keep the subject line under 70 characters. Use the body for context when needed.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Svelte 5 runes** — Use `$state`, `$derived`, `$props`, `$effect` (not Svelte 4 stores)
|
||||
- **TypeScript** — Strict mode, no `any` unless absolutely necessary
|
||||
- **Tailwind CSS v4** — Use CSS variables from `src/app.css`, never hardcoded Tailwind colors
|
||||
- **Design system** — Follow the Retro Parchment aesthetic described in `CLAUDE.md`
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: Pure logic (parsing, auth, sync). Run with `make test-unit`
|
||||
- **E2E tests**: User workflows in the browser. Run with `make test-e2e`
|
||||
- E2E tests use Gherkin-style Given/When/Then comments (see `CLAUDE.md` for conventions)
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Use [GitHub Issues](https://github.com/bretzel-app/crumbs/issues) with the bug report template. Include:
|
||||
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Browser/OS and Crumbs version
|
||||
|
||||
## Security Issues
|
||||
|
||||
**Do not open public issues for security vulnerabilities.** See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present Bretzel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,7 @@
|
||||
.PHONY: help dev build preview check test test-unit test-e2e lint \
|
||||
db-push db-generate db-migrate db-studio docs-api \
|
||||
install docker-build docker-up docker-down docker-logs clean
|
||||
install docker-build docker-up docker-down docker-logs clean \
|
||||
release release-patch release-minor release-major
|
||||
|
||||
help: ## List all targets
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||
@@ -62,3 +63,15 @@ docker-logs: ## Tail container logs
|
||||
clean: ## Remove build artifacts and test DBs
|
||||
rm -rf build .svelte-kit node_modules/.vite
|
||||
rm -f data/test-crumbs.db
|
||||
|
||||
release: ## Auto-bump version from commits, tag, and push
|
||||
bash scripts/release.sh $(BUMP)
|
||||
|
||||
release-patch: ## Bump patch version, tag, and push
|
||||
bash scripts/release.sh patch
|
||||
|
||||
release-minor: ## Bump minor version, tag, and push
|
||||
bash scripts/release.sh minor
|
||||
|
||||
release-major: ## Bump major version, tag, and push
|
||||
bash scripts/release.sh major
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
# 🥨 Crumbs by Bretzel
|
||||
[](https://github.com/bretzel-app/crumbs/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://github.com/bretzel-app/crumbs/releases)
|
||||
[](https://github.com/bretzel-app/crumbs/pkgs/container/crumbs)
|
||||
|
||||
<p align="center">
|
||||
<img src="static/favicon-96x96.png" alt="Crumbs logo" width="96" height="96">
|
||||
</p>
|
||||
|
||||
# Crumbs by Bretzel
|
||||
|
||||
A self-hostable, offline-first note-taking app inspired by Google Keep. Part of the [Bretzel](https://bretzel.app) app universe.
|
||||
|
||||
@@ -62,6 +71,9 @@ See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation.
|
||||
| [Architecture](docs/ARCHITECTURE.md) | Local-first sync design, tech rationale, DB schema |
|
||||
| [Deployment](docs/DEPLOYMENT.md) | Docker, Node.js, reverse proxy, backups, env vars |
|
||||
| [API](docs/API.md) | REST API reference (auto-generated) |
|
||||
| [Contributing](CONTRIBUTING.md) | How to contribute |
|
||||
| [Security](SECURITY.md) | Vulnerability reporting policy |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
|
||||
## CI/CD
|
||||
|
||||
@@ -72,4 +84,4 @@ Configure registry via GitHub Secrets: `REGISTRY_URL`, `REGISTRY_USER`, `REGISTR
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[MIT](LICENSE)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| latest | Yes |
|
||||
| < latest | No |
|
||||
|
||||
We recommend always running the latest version of Crumbs.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via [GitHub's private vulnerability reporting](https://github.com/bretzel-app/crumbs/security/advisories/new).
|
||||
|
||||
Include as much of the following as possible:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Impact assessment
|
||||
- Suggested fix (if any)
|
||||
|
||||
You should receive an initial response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
|
||||
|
||||
## Scope
|
||||
|
||||
Since Crumbs is a self-hosted application, the following are in scope:
|
||||
|
||||
- Authentication bypass or session hijacking
|
||||
- SQL injection or data leaks
|
||||
- Cross-site scripting (XSS)
|
||||
- Server-side request forgery (SSRF)
|
||||
- Path traversal or file access vulnerabilities
|
||||
- Denial of service via crafted input
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow coordinated disclosure. Once a fix is available, we will:
|
||||
|
||||
1. Release a patched version
|
||||
2. Publish a security advisory on GitHub
|
||||
3. Credit the reporter (unless anonymity is requested)
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:0.3.3
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=http://localhost:3000
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
+24
@@ -103,6 +103,7 @@ List notes
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -125,6 +126,7 @@ Create a note
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -147,6 +149,7 @@ Create a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -181,6 +184,7 @@ Get a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -212,6 +216,7 @@ Partial update — only include changed fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -234,6 +239,7 @@ Partial update — only include changed fields.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -288,6 +294,19 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/notes/{id}/attachments`
|
||||
@@ -366,6 +385,7 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -423,6 +443,7 @@ Pull changes since timestamp
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -451,6 +472,7 @@ Push local changes
|
||||
"operation": "create",
|
||||
"timestamp": 1,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -473,4 +495,6 @@ Push local changes
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `400` Invalid changes payload
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "8080:3000" # Change external port
|
||||
volumes:
|
||||
|
||||
+15
-4
@@ -8,16 +8,27 @@
|
||||
- Content supports full Markdown syntax
|
||||
- Auto-save on editor close
|
||||
|
||||
### Markdown Support
|
||||
- **Bold** (`**text**`)
|
||||
- *Italic* (`*text*`)
|
||||
### Rich Text Editor
|
||||
- WYSIWYG editing powered by Tiptap
|
||||
- Formatting toolbar with visual controls
|
||||
- Raw Markdown mode toggle for direct editing
|
||||
- Undo/Redo with keyboard shortcuts
|
||||
|
||||
### Text Formatting
|
||||
- **Bold** (`**text**` / Ctrl+B)
|
||||
- *Italic* (`*text*` / Ctrl+I)
|
||||
- Underline (Ctrl+U)
|
||||
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
|
||||
- Headings (`# H1` through `###### H6`)
|
||||
- Blockquotes (`> quote`)
|
||||
- Unordered lists (`- item`)
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links (auto-linkified URLs)
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
|
||||
### Checklists / Task Lists
|
||||
|
||||
+17
-1
@@ -187,7 +187,13 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment list or file content
|
||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
post:
|
||||
summary: Upload an image attachment
|
||||
tags: [Attachments]
|
||||
@@ -306,6 +312,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'400':
|
||||
description: Invalid changes payload
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -340,6 +348,10 @@ components:
|
||||
type: boolean
|
||||
trashed:
|
||||
type: boolean
|
||||
trashedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checklistMode:
|
||||
type: boolean
|
||||
sortOrder:
|
||||
@@ -360,6 +372,10 @@ components:
|
||||
NoteInput:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Optional — generated if omitted
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
|
||||
+42
-30
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bretzel (https://bretzel.app)",
|
||||
"homepage": "https://crumbs.bretzel.app",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/bretzel-app/crumbs.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -22,43 +29,43 @@
|
||||
"docs:api": "tsx scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@tiptap/core": "^3.20.0",
|
||||
"@tiptap/extension-link": "^3.20.0",
|
||||
"@tiptap/extension-placeholder": "^3.20.0",
|
||||
"@tiptap/extension-task-item": "^3.20.0",
|
||||
"@tiptap/extension-task-list": "^3.20.0",
|
||||
"@tiptap/extension-text-align": "^3.20.0",
|
||||
"@tiptap/extension-underline": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"argon2": "^0.43.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"idb": "^8.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tiptap/core": "^3.20.1",
|
||||
"@tiptap/extension-link": "^3.20.1",
|
||||
"@tiptap/extension-placeholder": "^3.20.1",
|
||||
"@tiptap/extension-task-item": "^3.20.1",
|
||||
"@tiptap/extension-task-list": "^3.20.1",
|
||||
"@tiptap/extension-text-align": "^3.20.1",
|
||||
"@tiptap/extension-underline": "^3.20.1",
|
||||
"@tiptap/pm": "^3.20.1",
|
||||
"@tiptap/starter-kit": "^3.20.1",
|
||||
"argon2": "^0.43.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/kit": "^2.53.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"svelte": "^5.53.7",
|
||||
"svelte-check": "^4.4.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -66,6 +73,11 @@
|
||||
"argon2",
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"esbuild@<0.25.0": "0.25.12",
|
||||
"cookie": ">=0.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+300
-531
File diff suppressed because it is too large
Load Diff
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Automate version bump, tag, and push based on conventional commits.
|
||||
# Usage: bash scripts/release.sh [patch|minor|major]
|
||||
# If no argument is given, the bump type is inferred from commits since the last tag.
|
||||
|
||||
BUMP="${1:-}"
|
||||
|
||||
# --- Safety checks ---
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$BRANCH" != "main" ]]; then
|
||||
echo "Error: must be on the main branch (currently on '$BRANCH')" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Error: working tree is not clean" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main --tags --quiet
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
if [[ "$LOCAL" != "$REMOTE" ]]; then
|
||||
echo "Error: local main is not in sync with origin/main" >&2
|
||||
echo " local: $LOCAL" >&2
|
||||
echo " remote: $REMOTE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Bump type inference ---
|
||||
|
||||
if [[ -z "$BUMP" ]]; then
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "No previous v* tag found, defaulting to patch"
|
||||
BUMP="patch"
|
||||
else
|
||||
COMMITS=$(git log "$LAST_TAG"..HEAD --pretty=format:'%s%n%b')
|
||||
if echo "$COMMITS" | grep -qiE 'BREAKING CHANGE:|^[a-z]+(\(.*\))?!:'; then
|
||||
BUMP="major"
|
||||
elif echo "$COMMITS" | grep -qE '^feat(\(.*\))?:'; then
|
||||
BUMP="minor"
|
||||
else
|
||||
BUMP="patch"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
|
||||
echo "Error: invalid bump type '$BUMP' (must be patch, minor, or major)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Bump, tag, push ---
|
||||
|
||||
echo "Bumping $BUMP version..."
|
||||
npm version "$BUMP" -m "chore: bump version to %s" --no-git-tag-version
|
||||
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
git add package.json
|
||||
git commit -m "chore: bump version to $NEW_VERSION"
|
||||
git tag "v$NEW_VERSION"
|
||||
|
||||
echo "Pushing to origin..."
|
||||
git push origin main --tags
|
||||
|
||||
echo ""
|
||||
echo "Released v$NEW_VERSION ($BUMP bump)"
|
||||
echo "GitHub Actions release workflow will handle the rest."
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"drizzle-migrations": {
|
||||
"source": "erichowens/some_claude_skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "d2af96b92935e26f53d97616aa51a02f114c9da8129c5943408d3b4b19a10423"
|
||||
},
|
||||
"svelte5-best-practices": {
|
||||
"source": "ejirocodes/agent-skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "bfcbba3d355aede6f6be9720f195206d4dcaa7c152787ea00c06cb4a1ac44ba4"
|
||||
},
|
||||
"sveltekit-data-flow": {
|
||||
"source": "spences10/svelte-skills-kit",
|
||||
"sourceType": "github",
|
||||
"computedHash": "1ef78409b38d1cb7324d8e0a78ac3f4303e0a9c5b5789bfc2d646c0806faa618"
|
||||
},
|
||||
"sveltekit-structure": {
|
||||
"source": "spences10/svelte-skills-kit",
|
||||
"sourceType": "github",
|
||||
"computedHash": "a935fd51b32e4e37d23286c04748825a6a193ef74d9980385449aa4f80646f9d"
|
||||
},
|
||||
"typescript-best-practices": {
|
||||
"source": "0xbigboss/claude-code",
|
||||
"sourceType": "github",
|
||||
"computedHash": "838d6737e59e8b15457a6af23112bc3a107a8ac098606a19a43c2be0db2dbe5a"
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -1,2 +1,74 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
:root {
|
||||
--bg-base: #f0e6d3;
|
||||
--bg-surface: #faf5eb;
|
||||
--primary: #C8860A;
|
||||
--primary-hover: #E8A020;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b6272;
|
||||
--border: #1a1a2e;
|
||||
--border-subtle: #d4cabb;
|
||||
--destructive: #a63d2f;
|
||||
--error-bg: #f5e0db;
|
||||
--error-border: #d4a69e;
|
||||
--error-text: #8b2e1f;
|
||||
--success-bg: #dce8d4;
|
||||
--success-text: #3a5a2e;
|
||||
--card-shadow: 2px 2px 0px var(--border-subtle);
|
||||
--card-shadow-hover: 3px 3px 0px var(--primary);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.03;
|
||||
background-image: repeating-conic-gradient(#1a1a2e 0% 25%, transparent 0% 50%);
|
||||
background-size: 4px 4px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Editor entrance animations */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* Prose overrides for parchment theme */
|
||||
.prose {
|
||||
--tw-prose-body: var(--text);
|
||||
--tw-prose-headings: var(--text);
|
||||
--tw-prose-links: var(--primary);
|
||||
--tw-prose-code: var(--text);
|
||||
--tw-prose-pre-bg: var(--bg-surface);
|
||||
--tw-prose-pre-code: var(--text);
|
||||
}
|
||||
|
||||
.prose :where(code):not(:where([class~="not-prose"] *)) {
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: 2px;
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
|
||||
.prose :where(a):not(:where([class~="not-prose"] *)) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
+9
-5
@@ -3,11 +3,15 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
|
||||
/>
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Crumbs" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>Crumbs</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
@@ -83,7 +86,7 @@
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(index)}
|
||||
class="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||
class="h-4 w-4 rounded border-[var(--border-subtle)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
@@ -91,7 +94,7 @@
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-gray-400 line-through' : 'text-gray-800'}"
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
@@ -101,20 +104,16 @@
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<XIcon class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
|
||||
class="flex items-center gap-2 text-sm text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{@const colors = NOTE_COLORS[option.value]}
|
||||
<button
|
||||
onclick={() => onSelect(option.value)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800' : 'border-transparent'}"
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-[var(--text)]' : 'border-[var(--border-subtle)]'}"
|
||||
style="background-color: {colors.bg}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Minus,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Ellipsis
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -80,15 +81,15 @@
|
||||
const chevronSize = 14;
|
||||
|
||||
function btnClass(active: boolean = false, disabled: boolean = false): string {
|
||||
return `rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
return `shrink-0 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''} ${disabled ? 'opacity-30' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownBtnClass(active: boolean = false): string {
|
||||
return `flex items-center gap-0.5 rounded p-1.5 text-gray-600 hover:bg-black/10 ${active ? 'bg-black/10' : ''}`;
|
||||
return `shrink-0 flex items-center gap-0.5 rounded p-1.5 text-[var(--text)] hover:bg-[var(--border)]/10 ${active ? 'text-[var(--primary)] bg-[var(--border)]/10' : ''}`;
|
||||
}
|
||||
|
||||
function dropdownItemClass(active: boolean = false): string {
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-black/5 ${active ? 'bg-black/5' : ''}`;
|
||||
return `flex w-full items-center gap-2 rounded px-3 py-1.5 text-sm text-[var(--text)] hover:bg-[var(--border)]/5 ${active ? 'bg-[var(--border)]/5' : ''}`;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
@@ -109,124 +110,9 @@
|
||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-0.5 border-b border-gray-200/50 px-2 py-1"
|
||||
class="flex items-center gap-0.5 border-b border-[var(--border-subtle)] px-2 py-1"
|
||||
data-testid="formatting-toolbar"
|
||||
>
|
||||
<!-- History -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().undo().run()}
|
||||
disabled={!canUndo()}
|
||||
class={btnClass(false, !canUndo())}
|
||||
title="Undo (Ctrl+Z)"
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().redo().run()}
|
||||
disabled={!canRedo()}
|
||||
class={btnClass(false, !canRedo())}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-gray-400">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-gray-400">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-gray-400">H3</span>
|
||||
<span>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[170px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
class={btnClass(editor?.isActive('blockquote') ?? false)}
|
||||
title="Blockquote"
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
class={btnClass(editor?.isActive('codeBlock') ?? false)}
|
||||
title="Code block"
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- Inline -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||
@@ -252,14 +138,6 @@
|
||||
>
|
||||
<Strikethrough size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleCode().run()}
|
||||
class={btnClass(editor?.isActive('code') ?? false)}
|
||||
title="Inline code (Ctrl+E)"
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
class={btnClass(editor?.isActive('underline') ?? false)}
|
||||
@@ -269,6 +147,82 @@
|
||||
<Underline size={iconSize} />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="relative" data-dropdown="heading">
|
||||
<button
|
||||
onclick={() => toggleDropdown('heading')}
|
||||
class={dropdownBtnClass(editor?.isActive('heading') ?? false)}
|
||||
title="Heading"
|
||||
data-testid="format-heading"
|
||||
>
|
||||
<Heading size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'heading'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[150px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 1 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 1 }) ?? false)}
|
||||
data-testid="format-h1"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H1</span>
|
||||
<span class="font-semibold">Heading 1</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 2 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 2 }) ?? false)}
|
||||
data-testid="format-h2"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H2</span>
|
||||
<span>Heading 2</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleHeading({ level: 3 }).run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('heading', { level: 3 }) ?? false)}
|
||||
data-testid="format-h3"
|
||||
>
|
||||
<span class="w-6 text-xs font-semibold text-[var(--text-muted)]">H3</span>
|
||||
<span>Heading 3</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- List dropdown -->
|
||||
<div class="relative" data-dropdown="list">
|
||||
<button
|
||||
onclick={() => toggleDropdown('list')}
|
||||
class={dropdownBtnClass((editor?.isActive('bulletList') || editor?.isActive('orderedList')) ?? false)}
|
||||
title="Lists"
|
||||
data-testid="format-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'list'}
|
||||
<div class="absolute left-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBulletList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('bulletList') ?? false)}
|
||||
data-testid="format-bullet-list"
|
||||
>
|
||||
<List size={iconSize} />
|
||||
<span>Bullet list</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleOrderedList().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('orderedList') ?? false)}
|
||||
data-testid="format-ordered-list"
|
||||
>
|
||||
<ListOrdered size={iconSize} />
|
||||
<span>Ordered list</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Link popover -->
|
||||
<div class="relative" data-dropdown="link">
|
||||
<button
|
||||
@@ -280,7 +234,7 @@
|
||||
<Link size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'link'}
|
||||
<div class="absolute left-1/2 top-full z-50 mt-1 -translate-x-1/2 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-lg">
|
||||
<div class="absolute left-1/2 bottom-full z-50 mb-1 -translate-x-1/2 rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1.5">
|
||||
<form
|
||||
class="flex items-center gap-1"
|
||||
onsubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
@@ -290,23 +244,23 @@
|
||||
bind:value={linkUrl}
|
||||
type="url"
|
||||
placeholder="Paste a link..."
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-sm text-gray-700 outline-none placeholder:text-gray-400"
|
||||
class="w-44 bg-transparent px-1.5 py-1 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="format-link-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)]"
|
||||
title="Apply link"
|
||||
data-testid="format-link-apply"
|
||||
>
|
||||
<CornerDownLeft size={16} />
|
||||
</button>
|
||||
<div class="mx-0.5 h-4 w-px bg-gray-300"></div>
|
||||
<div class="mx-0.5 h-4 w-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openLink}
|
||||
disabled={!editor?.getAttributes('link').href}
|
||||
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-gray-600 disabled:opacity-30"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-[var(--text)] disabled:opacity-30"
|
||||
title="Open link"
|
||||
data-testid="format-link-open"
|
||||
>
|
||||
@@ -316,7 +270,7 @@
|
||||
type="button"
|
||||
onclick={removeLink}
|
||||
disabled={!editor?.isActive('link')}
|
||||
class="rounded p-1 text-gray-400 hover:bg-black/5 hover:text-red-500 disabled:opacity-30"
|
||||
class="rounded p-1 text-[var(--text-muted)] hover:bg-[var(--border)]/5 hover:text-red-500 disabled:opacity-30"
|
||||
title="Remove link"
|
||||
data-testid="format-unlink"
|
||||
>
|
||||
@@ -327,28 +281,79 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-gray-300"></div>
|
||||
<div class="mx-1 h-4 w-px shrink-0 bg-[var(--border-subtle)]"></div>
|
||||
|
||||
<!-- Align dropdown -->
|
||||
<div class="relative" data-dropdown="align">
|
||||
<!-- More dropdown -->
|
||||
<div class="relative" data-dropdown="more">
|
||||
<button
|
||||
onclick={() => toggleDropdown('align')}
|
||||
class={dropdownBtnClass(editor?.isActive({ textAlign: 'center' }) || editor?.isActive({ textAlign: 'right' }) || editor?.isActive({ textAlign: 'justify' }) ? true : false)}
|
||||
title="Text alignment"
|
||||
data-testid="format-align"
|
||||
onclick={() => toggleDropdown('more')}
|
||||
class={dropdownBtnClass(false)}
|
||||
title="More options"
|
||||
data-testid="format-more"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<ChevronDown size={chevronSize} />
|
||||
<Ellipsis size={iconSize} />
|
||||
</button>
|
||||
{#if openDropdown === 'align'}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 min-w-[150px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{#if openDropdown === 'more'}
|
||||
<div class="absolute right-0 bottom-full z-50 mb-1 min-w-[170px] rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] py-1">
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().undo().run(); closeDropdowns(); }}
|
||||
disabled={!canUndo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-undo"
|
||||
>
|
||||
<Undo2 size={iconSize} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().redo().run(); closeDropdowns(); }}
|
||||
disabled={!canRedo()}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-redo"
|
||||
>
|
||||
<Redo2 size={iconSize} />
|
||||
<span>Redo</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCode().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('code') ?? false)}
|
||||
data-testid="format-code"
|
||||
>
|
||||
<Code size={iconSize} />
|
||||
<span>Inline code</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleBlockquote().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('blockquote') ?? false)}
|
||||
data-testid="format-blockquote"
|
||||
>
|
||||
<TextQuote size={iconSize} />
|
||||
<span>Blockquote</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().toggleCodeBlock().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive('codeBlock') ?? false)}
|
||||
data-testid="format-code-block"
|
||||
>
|
||||
<CodeXml size={iconSize} />
|
||||
<span>Code block</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setHorizontalRule().run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass()}
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('left').run(); closeDropdowns(); }}
|
||||
class={dropdownItemClass(editor?.isActive({ textAlign: 'left' }) ?? false)}
|
||||
data-testid="format-align-left"
|
||||
>
|
||||
<AlignLeft size={iconSize} />
|
||||
<span>Left</span>
|
||||
<span>Align left</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('center').run(); closeDropdowns(); }}
|
||||
@@ -356,7 +361,7 @@
|
||||
data-testid="format-align-center"
|
||||
>
|
||||
<AlignCenter size={iconSize} />
|
||||
<span>Center</span>
|
||||
<span>Align center</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('right').run(); closeDropdowns(); }}
|
||||
@@ -364,7 +369,7 @@
|
||||
data-testid="format-align-right"
|
||||
>
|
||||
<AlignRight size={iconSize} />
|
||||
<span>Right</span>
|
||||
<span>Align right</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editor?.chain().focus().setTextAlign('justify').run(); closeDropdowns(); }}
|
||||
@@ -377,16 +382,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mx-1 h-4 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- Insert -->
|
||||
<button
|
||||
onclick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
class={btnClass()}
|
||||
title="Horizontal rule"
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import XIcon from 'lucide-svelte/icons/x';
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
@@ -60,7 +61,7 @@
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded object-cover"
|
||||
class="h-20 w-20 rounded-sm object-cover"
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
<button
|
||||
@@ -69,9 +70,7 @@
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -80,7 +79,7 @@
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50' : 'border-gray-300'}"
|
||||
class="rounded-sm border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-[var(--primary)] bg-[var(--primary)]/5' : 'border-[var(--border-subtle)]'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
@@ -89,10 +88,10 @@
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if uploading}
|
||||
<p class="text-sm text-gray-500">Uploading...</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
|
||||
<span class="text-sm text-[var(--text-muted)]">Drop images here or <span class="text-[var(--primary)] underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
let { onMenuToggle }: Props = $props();
|
||||
let mobileSearchOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm">
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-full p-2 hover:bg-gray-100"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-6 w-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-[var(--border)] bg-[var(--bg-surface)] px-4">
|
||||
{#if mobileSearchOpen}
|
||||
<!-- Mobile expanded search -->
|
||||
<div class="flex flex-1 items-center gap-2 lg:hidden">
|
||||
<SearchBar onClose={() => (mobileSearchOpen = false)} />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="h-6 w-6 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥨</span>
|
||||
<h1 class="text-xl font-semibold text-gray-800">Crumbs</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="h-8 w-8" />
|
||||
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 flex-1">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<!-- Desktop search bar -->
|
||||
<div class="mx-4 hidden flex-1 lg:block">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
<!-- Mobile search icon -->
|
||||
<button
|
||||
onclick={() => (mobileSearchOpen = true)}
|
||||
class="ml-auto rounded-sm p-2 hover:bg-[var(--border)]/10 lg:hidden"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search class="h-5 w-5 text-[var(--text)]" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { open }: Props = $props();
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
@@ -20,15 +21,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 top-16 z-10 bg-black/30 lg:hidden"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<aside
|
||||
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
|
||||
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-[var(--border)] bg-[var(--bg-surface)] transition-transform duration-200 {open ? 'translate-x-0' : '-translate-x-full'}"
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
Crumbs
|
||||
@@ -37,7 +47,7 @@
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Archive size={20} />
|
||||
Archive
|
||||
@@ -46,7 +56,7 @@
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
Trash
|
||||
@@ -55,14 +65,14 @@
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4">
|
||||
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</h3>
|
||||
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
|
||||
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900' : 'text-gray-700 hover:bg-gray-100'}"
|
||||
class="flex w-full items-center gap-3 rounded-sm px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-[var(--primary)]/15 text-[var(--primary)]' : 'text-[var(--text)] hover:bg-[var(--bg-base)]'}"
|
||||
>
|
||||
<Tag size={16} />
|
||||
#{tag}
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function showToast(
|
||||
text: string,
|
||||
type: 'info' | 'success' | 'error' = 'info',
|
||||
action?: { label: string; handler: () => void },
|
||||
duration = 4000
|
||||
) {
|
||||
const id = ++counter;
|
||||
toasts.update((t) => [...t, { id, text, type, action }]);
|
||||
if (!action) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.update((t) => t.filter((toast) => toast.id !== id));
|
||||
}
|
||||
import { toasts, dismiss } from '$lib/stores/toast.js';
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white'}"
|
||||
class="flex items-center gap-3 rounded-sm border px-4 py-3 shadow-[var(--card-shadow)] {toast.type === 'error' ? 'border-[var(--error-border)] bg-[var(--error-bg)] text-[var(--error-text)]' : toast.type === 'success' ? 'border-[var(--border-subtle)] bg-[var(--success-bg)] text-[var(--success-text)]' : 'border-[var(--border)] bg-[var(--text)] text-[var(--bg-base)]'}"
|
||||
>
|
||||
<span class="text-sm">{toast.text}</span>
|
||||
{#if toast.action}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
@@ -40,10 +46,28 @@
|
||||
fn();
|
||||
};
|
||||
}
|
||||
|
||||
function toggleChecklistItem(index: number) {
|
||||
const lines = note.content.split('\n');
|
||||
let checkIndex = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('- [x] ') || line.startsWith('- [ ] ')) {
|
||||
if (checkIndex === index) {
|
||||
lines[i] = line.startsWith('- [x] ')
|
||||
? `- [ ] ${line.slice(6)}`
|
||||
: `- [x] ${line.slice(6)}`;
|
||||
break;
|
||||
}
|
||||
checkIndex++;
|
||||
}
|
||||
}
|
||||
updateNote(note.id, { content: lines.join('\n') });
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)]"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
@@ -52,24 +76,41 @@
|
||||
data-testid="note-card"
|
||||
data-note-id={note.id}
|
||||
>
|
||||
{#if note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||
title="Unpin"
|
||||
data-testid="pin-indicator"
|
||||
>
|
||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if note.title}
|
||||
<h3 class="mb-2 text-sm font-semibold text-gray-900">{note.title}</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||
{/if}
|
||||
|
||||
{#if note.checklistMode && checklistItems.length > 0}
|
||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
||||
{#each checklistItems.slice(0, 8) as item}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-gray-400 line-through' : 'text-gray-700'}">
|
||||
<input type="checkbox" checked={item.checked} disabled class="h-3.5 w-3.5 rounded border-gray-300 text-amber-600" />
|
||||
{#each checklistItems.slice(0, 8) as item, index}
|
||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(index); }}
|
||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||
data-testid="card-checklist-checkbox"
|
||||
/>
|
||||
<span class="truncate">{item.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
{#if checklistItems.length > 8}
|
||||
<li class="text-xs text-gray-400">+{checklistItems.length - 8} more</li>
|
||||
<li class="text-xs text-[var(--text-muted)]">+{checklistItems.length - 8} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if note.content}
|
||||
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-gray-700" data-testid="note-content-preview">
|
||||
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -77,9 +118,7 @@
|
||||
{#if note.tags && note.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each note.tags as tag}
|
||||
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600">
|
||||
#{tag}
|
||||
</span>
|
||||
<TagChip {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -89,79 +128,61 @@
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Restore"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
<Undo2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete forever"
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
title={note.pinned ? 'Unpin' : 'Pin'}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if !note.pinned}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Pin"
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<Bookmark class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Unarchive"
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<ArchiveRestore class="h-4 w-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Archive"
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<Archive class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10"
|
||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pin indicator -->
|
||||
{#if note.pinned}
|
||||
<div class="absolute right-2 top-2">
|
||||
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Note, NoteColor } from '$lib/types/index.js';
|
||||
import Palette from 'lucide-svelte/icons/palette';
|
||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||
import Type from 'lucide-svelte/icons/type';
|
||||
import FileCode from 'lucide-svelte/icons/file-code';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -71,14 +76,14 @@
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
|
||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||
onclick={save}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl"
|
||||
class="mx-4 flex w-full max-w-xl flex-col rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
@@ -89,33 +94,35 @@
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500"
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="note-title-input"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
</div>
|
||||
{:else if rawMarkdownMode}
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
{:else}
|
||||
<TiptapEditor
|
||||
{content}
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
</div>
|
||||
{:else if rawMarkdownMode}
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
placeholder="Add a crumb..."
|
||||
bind:value={content}
|
||||
class="min-h-[300px] w-full resize-none bg-transparent px-4 py-2 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
rows="12"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
{:else}
|
||||
<TiptapEditor
|
||||
{content}
|
||||
onUpdate={(md) => (content = md)}
|
||||
onEditor={(e) => (tiptapEditor = e)}
|
||||
onTransaction={() => editorTick++}
|
||||
placeholder="Add a crumb..."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Formatting toolbar -->
|
||||
{#if !rawMarkdownMode && !checklistMode}
|
||||
@@ -123,22 +130,20 @@
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2">
|
||||
<div class="flex items-center justify-between border-t border-[var(--border-subtle)] px-2 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Color picker toggle -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-full p-2 hover:bg-black/10"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Background color"
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg">
|
||||
<div class="absolute left-0 bottom-full z-10 mb-2 w-[calc(100vw-4rem)] max-w-xs rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-2">
|
||||
<ColorPicker selected={color} onSelect={handleColorSelect} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -147,31 +152,35 @@
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-full p-2 hover:bg-black/10 {checklistMode ? 'text-amber-600' : ''}"
|
||||
title="Checklist mode"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={checklistMode ? 'Switch to text' : 'Checklist mode'}
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{#if checklistMode}
|
||||
<Type class="h-5 w-5" />
|
||||
{:else}
|
||||
<SquareCheck class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Raw markdown mode toggle -->
|
||||
<button
|
||||
onclick={toggleMarkdownMode}
|
||||
class="rounded-full p-2 hover:bg-black/10 {rawMarkdownMode ? 'text-amber-600' : ''}"
|
||||
title="Markdown mode"
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title={rawMarkdownMode ? 'Rich text mode' : 'Markdown mode'}
|
||||
data-testid="markdown-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
{#if rawMarkdownMode}
|
||||
<FileText class="h-5 w-5" />
|
||||
{:else}
|
||||
<FileCode class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10"
|
||||
class="rounded-sm px-4 py-1 text-sm font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500">{label}</p>
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
|
||||
interface Props {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (onClose) inputEl?.focus();
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
@@ -34,26 +48,34 @@
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearSearch();
|
||||
onClose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative max-w-2xl">
|
||||
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2">
|
||||
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<div class="relative flex-1 max-w-2xl">
|
||||
<div class="flex items-center rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-2 focus-within:border-[var(--primary)]">
|
||||
{#if onClose}
|
||||
<button onclick={close} class="mr-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Back">
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<Search class="mr-3 h-5 w-5 text-[var(--text-muted)]" />
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
placeholder="Search crumbs..."
|
||||
bind:value={query}
|
||||
oninput={handleSearch}
|
||||
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500"
|
||||
class="w-full bg-transparent text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)]"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700" aria-label="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<button onclick={clearSearch} class="ml-2 text-[var(--text-muted)] hover:text-[var(--text)]" aria-label="Clear search">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
<script lang="ts">
|
||||
// Placeholder for Phase 6 - will be connected to sync store
|
||||
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { getSyncStatus, onSyncStatusChange, type SyncStatus } from '$lib/sync/client.js';
|
||||
import CloudCheck from 'lucide-svelte/icons/cloud-check';
|
||||
import CloudSync from 'lucide-svelte/icons/cloud-sync';
|
||||
import CloudOff from 'lucide-svelte/icons/cloud-off';
|
||||
import CloudAlert from 'lucide-svelte/icons/cloud-alert';
|
||||
|
||||
let status: SyncStatus = $state(getSyncStatus());
|
||||
let displayStatus: SyncStatus = $state(getSyncStatus());
|
||||
let minDisplayTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingStatus: SyncStatus | undefined;
|
||||
|
||||
function applyStatus(s: SyncStatus) {
|
||||
if (displayStatus === s) return;
|
||||
|
||||
if (minDisplayTimeout) {
|
||||
pendingStatus = s;
|
||||
return;
|
||||
}
|
||||
|
||||
displayStatus = s;
|
||||
minDisplayTimeout = setTimeout(() => {
|
||||
minDisplayTimeout = undefined;
|
||||
if (pendingStatus !== undefined && pendingStatus !== displayStatus) {
|
||||
applyStatus(pendingStatus);
|
||||
pendingStatus = undefined;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = onSyncStatusChange((s) => {
|
||||
status = s;
|
||||
applyStatus(s);
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (minDisplayTimeout) clearTimeout(minDisplayTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
||||
{#if status === 'synced'}
|
||||
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else if status === 'syncing'}
|
||||
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{:else if status === 'offline'}
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
{#if displayStatus === 'synced'}
|
||||
<CloudCheck class="h-5 w-5 text-[var(--primary)]" />
|
||||
{:else if displayStatus === 'syncing'}
|
||||
<CloudSync class="h-5 w-5 text-[var(--primary)] opacity-60" />
|
||||
{:else if displayStatus === 'offline'}
|
||||
<CloudOff class="h-5 w-5 text-[var(--text-muted)]" />
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<CloudAlert class="h-5 w-5 text-[var(--destructive)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
tag: string;
|
||||
active?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { tag, active = false, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if onclick}
|
||||
<button
|
||||
{onclick}
|
||||
class="rounded-sm px-2 py-0.5 text-xs font-medium transition-colors {active
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'border border-[var(--border-subtle)] bg-[var(--bg-surface)] text-[var(--text-muted)] hover:border-[var(--primary)]'}"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] px-2 py-0.5 text-xs font-medium text-[var(--text-muted)]"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import TagChip from './TagChip.svelte';
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedTag.update((current) => (current === tag ? null : tag));
|
||||
@@ -9,13 +10,7 @@
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<button
|
||||
onclick={() => toggleTag(tag)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
<TagChip {tag} active={$selectedTag === tag} onclick={() => toggleTag(tag)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-gray-800"
|
||||
class="tiptap-wrapper prose prose-sm min-h-[300px] max-w-none px-4 py-2 text-[var(--text)]"
|
||||
data-testid="tiptap-editor"
|
||||
></div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
.tiptap-wrapper :global(.tiptap p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #9ca3af;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from './db/index.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
@@ -61,12 +61,5 @@ export async function deleteSession(token: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function cleanExpiredSessions(): Promise<void> {
|
||||
const now = new Date();
|
||||
// Delete all expired sessions by checking each one
|
||||
const allSessions = await db.select().from(sessions);
|
||||
for (const session of allSessions) {
|
||||
if (session.expiresAt < now) {
|
||||
await db.delete(sessions).where(eq(sessions.id, session.id));
|
||||
}
|
||||
}
|
||||
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ sqlite.exec(`
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
|
||||
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
|
||||
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
|
||||
`);
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
+51
-33
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
@@ -14,21 +14,28 @@ export const sessions = sqliteTable('sessions', {
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const notes = sqliteTable('notes', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').default('').notNull(),
|
||||
content: text('content').default('').notNull(),
|
||||
color: text('color').default('default').notNull(),
|
||||
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
|
||||
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
version: integer('version').default(1).notNull()
|
||||
});
|
||||
export const notes = sqliteTable(
|
||||
'notes',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').default('').notNull(),
|
||||
content: text('content').default('').notNull(),
|
||||
color: text('color').default('default').notNull(),
|
||||
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
|
||||
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
version: integer('version').default(1).notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
|
||||
index('notes_updated_at_idx').on(table.updatedAt)
|
||||
]
|
||||
);
|
||||
|
||||
export const tags = sqliteTable(
|
||||
'tags',
|
||||
@@ -39,14 +46,21 @@ export const tags = sqliteTable(
|
||||
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
|
||||
);
|
||||
|
||||
export const noteTags = sqliteTable('note_tags', {
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
});
|
||||
export const noteTags = sqliteTable(
|
||||
'note_tags',
|
||||
{
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('note_tags_note_id_idx').on(table.noteId),
|
||||
index('note_tags_tag_id_idx').on(table.tagId)
|
||||
]
|
||||
);
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -60,12 +74,16 @@ export const attachments = sqliteTable('attachments', {
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const syncLog = sqliteTable('sync_log', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id)
|
||||
.notNull(),
|
||||
operation: text('operation').notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
|
||||
clientId: text('client_id').notNull()
|
||||
});
|
||||
export const syncLog = sqliteTable(
|
||||
'sync_log',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id)
|
||||
.notNull(),
|
||||
operation: text('operation').notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
|
||||
clientId: text('client_id').notNull()
|
||||
},
|
||||
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { db } from './db/index.js';
|
||||
import { noteTags, tags } from './db/schema.js';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Batch fetch tags for multiple notes in a single query.
|
||||
* Returns a Map of noteId → tag names.
|
||||
*/
|
||||
export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
|
||||
if (noteIds.length === 0) return new Map();
|
||||
|
||||
const rows = db
|
||||
.select({ noteId: noteTags.noteId, name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(inArray(noteTags.noteId, noteIds))
|
||||
.all();
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const existing = map.get(row.noteId);
|
||||
if (existing) {
|
||||
existing.push(row.name);
|
||||
} else {
|
||||
map.set(row.noteId, [row.name]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync tags for a note: removes old associations, upserts tags, creates new associations.
|
||||
* Should be called within a transaction for atomicity.
|
||||
*/
|
||||
export function syncNoteTags(noteId: string, tagNames: string[]) {
|
||||
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
|
||||
|
||||
for (const name of tagNames) {
|
||||
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
|
||||
if (!tagRow) {
|
||||
const result = db.insert(tags).values({ name }).returning().all();
|
||||
tagRow = result[0];
|
||||
}
|
||||
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
|
||||
}
|
||||
}
|
||||
+125
-30
@@ -1,9 +1,13 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
|
||||
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
export const notesLoaded = writable(false);
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
@@ -37,50 +41,141 @@ export const allTags = derived(notes, ($notes) => {
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
function isNetworkError(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
// Load from IDB first for instant display
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
}
|
||||
currentFilter.set(filter);
|
||||
notesLoaded.set(true);
|
||||
|
||||
// Then fetch from server in the background
|
||||
try {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
await clearIdbNotes();
|
||||
await Promise.all(data.map((n) => putNote(n)));
|
||||
}
|
||||
} catch {
|
||||
// Offline — IDB data already loaded above
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(note)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
notes.update((list) => [created, ...list]);
|
||||
return created;
|
||||
try {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(note)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Note = await res.json();
|
||||
notes.update((list) => [created, ...list]);
|
||||
await putNote(created);
|
||||
return created;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
const now = new Date();
|
||||
const title = note.title ?? '';
|
||||
const content = note.content ?? '';
|
||||
const optimistic: Note = {
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
content,
|
||||
color: note.color ?? 'default',
|
||||
pinned: note.pinned ?? false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: note.checklistMode ?? false,
|
||||
sortOrder: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
tags: extractTags(`${title} ${content}`)
|
||||
};
|
||||
notes.update((list) => [optimistic, ...list]);
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: optimistic.id, operation: 'create', data: optimistic, timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
return optimistic;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
||||
return updated;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated: Note = await res.json();
|
||||
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
||||
await putNote(updated);
|
||||
return updated;
|
||||
}
|
||||
showToast('Failed to save note', 'error');
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
let optimistic: Note | null = null;
|
||||
notes.update((list) =>
|
||||
list.map((n) => {
|
||||
if (n.id === id) {
|
||||
optimistic = { ...n, ...updates, updatedAt: new Date() };
|
||||
if (updates.title !== undefined || updates.content !== undefined) {
|
||||
optimistic.tags = extractTags(`${optimistic.title} ${optimistic.content}`);
|
||||
}
|
||||
return optimistic;
|
||||
}
|
||||
return n;
|
||||
})
|
||||
);
|
||||
if (optimistic) {
|
||||
await putNote(optimistic);
|
||||
await addToSyncQueue({ noteId: id, operation: 'update', data: updates, timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
}
|
||||
return optimistic;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
return true;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
await deleteNoteFromIdb(id);
|
||||
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
|
||||
showToast('Saved offline — will sync when reconnected', 'info');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function trashNote(id: string): Promise<Note | null> {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function showToast(
|
||||
text: string,
|
||||
type: 'info' | 'success' | 'error' = 'info',
|
||||
action?: { label: string; handler: () => void },
|
||||
duration = 4000
|
||||
) {
|
||||
const id = ++counter;
|
||||
toasts.update((t) => [...t, { id, text, type, action }]);
|
||||
if (!action) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.update((t) => t.filter((toast) => toast.id !== id));
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
@@ -98,6 +100,9 @@ export async function sync(): Promise<void> {
|
||||
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
|
||||
// Reload notes from server and persist to IDB
|
||||
await loadNotes(get(currentFilter));
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
|
||||
+74
-49
@@ -1,62 +1,86 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { syncNoteTags } from '$lib/server/tags.js';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
for (const change of changes) {
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
case 'update': {
|
||||
if (!change.data) continue;
|
||||
const existing = await db.select().from(notes).where(eq(notes.id, change.noteId)).get();
|
||||
const noteIdsToSyncTags: string[] = [];
|
||||
|
||||
if (existing) {
|
||||
// LWW: only update if incoming timestamp is newer
|
||||
if (change.timestamp > existing.updatedAt.getTime()) {
|
||||
await db
|
||||
.update(notes)
|
||||
.set({
|
||||
...change.data,
|
||||
db.transaction((tx) => {
|
||||
for (const change of changes) {
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
case 'update': {
|
||||
if (!change.data) continue;
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
if (change.timestamp > existing.updatedAt.getTime()) {
|
||||
tx.update(notes)
|
||||
.set({
|
||||
...change.data,
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
})
|
||||
.where(eq(notes.id, change.noteId))
|
||||
.run();
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
tx.insert(notes)
|
||||
.values({
|
||||
id: change.noteId,
|
||||
title: change.data.title || '',
|
||||
content: change.data.content || '',
|
||||
color: change.data.color || 'default',
|
||||
pinned: change.data.pinned || false,
|
||||
archived: change.data.archived || false,
|
||||
trashed: change.data.trashed || false,
|
||||
checklistMode: change.data.checklistMode || false,
|
||||
sortOrder: change.data.sortOrder || 0,
|
||||
createdAt: new Date(change.timestamp),
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
version: 1
|
||||
})
|
||||
.where(eq(notes.id, change.noteId));
|
||||
.run();
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
await db.insert(notes).values({
|
||||
id: change.noteId,
|
||||
title: change.data.title || '',
|
||||
content: change.data.content || '',
|
||||
color: change.data.color || 'default',
|
||||
pinned: change.data.pinned || false,
|
||||
archived: change.data.archived || false,
|
||||
trashed: change.data.trashed || false,
|
||||
checklistMode: change.data.checklistMode || false,
|
||||
sortOrder: change.data.sortOrder || 0,
|
||||
createdAt: new Date(change.timestamp),
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: 1
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
await db.delete(notes).where(eq(notes.id, change.noteId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync operation
|
||||
await db.insert(syncLog).values({
|
||||
noteId: change.noteId,
|
||||
operation: change.operation,
|
||||
timestamp: new Date(change.timestamp),
|
||||
clientId: 'default'
|
||||
});
|
||||
if (change.data.title !== undefined || change.data.content !== undefined) {
|
||||
noteIdsToSyncTags.push(change.noteId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
tx.delete(notes).where(eq(notes.id, change.noteId)).run();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tx.insert(syncLog)
|
||||
.values({
|
||||
noteId: change.noteId,
|
||||
operation: change.operation,
|
||||
timestamp: new Date(change.timestamp),
|
||||
clientId: 'default'
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
for (const noteId of noteIdsToSyncTags) {
|
||||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||||
if (note) {
|
||||
const content = `${note.title} ${note.content}`;
|
||||
syncNoteTags(noteId, extractTags(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +88,9 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
* Get all notes updated since a given timestamp.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number) {
|
||||
const since = new Date(sinceTimestamp);
|
||||
const allNotes = await db.select().from(notes);
|
||||
// Filter notes updated after the timestamp
|
||||
return allNotes.filter((n) => n.updatedAt > since);
|
||||
return db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(gt(notes.updatedAt, new Date(sinceTimestamp)))
|
||||
.all();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
export const NOTE_COLORS: Record<NoteColor, { bg: string; label: string }> = {
|
||||
default: { bg: '#ffffff', label: 'Default' },
|
||||
default: { bg: '#faf5eb', label: 'Default' },
|
||||
coral: { bg: '#faafa8', label: 'Coral' },
|
||||
peach: { bg: '#f39f76', label: 'Peach' },
|
||||
sand: { bg: '#fff8b8', label: 'Sand' },
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { version } from '../../../package.json';
|
||||
|
||||
export function load() {
|
||||
return { appVersion: version };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Layout/Header.svelte';
|
||||
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
|
||||
import Toast from '$lib/components/Layout/Toast.svelte';
|
||||
import { loadNotes } from '$lib/stores/notes.js';
|
||||
import { startSync, stopSync } from '$lib/sync/client.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
|
||||
loadNotes();
|
||||
startSync();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stopSync();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-[var(--bg-base)] text-[var(--text)]">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-[var(--text-muted)] {sidebarOpen ? 'lg:ml-64' : ''}">
|
||||
Crumbs by Bretzel v{data.appVersion} — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
@@ -2,20 +2,38 @@
|
||||
import NoteGrid from '$lib/components/NoteGrid.svelte';
|
||||
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||
import TagFilter from '$lib/components/TagFilter.svelte';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter } from '$lib/stores/notes.js';
|
||||
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
|
||||
let editingNote: Note | null = $state(null);
|
||||
let showNewNote = $state(false);
|
||||
|
||||
function openEditor(note: Note) {
|
||||
editingNote = note;
|
||||
history.replaceState(null, '', `#${note.id}`);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editingNote = null;
|
||||
showNewNote = false;
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const hash = location.hash.slice(1);
|
||||
if (!hash) return;
|
||||
|
||||
// Notes may not be loaded yet — wait for them
|
||||
const unsubscribe = notes.subscribe(($notes) => {
|
||||
if ($notes.length === 0) return;
|
||||
const note = $notes.find((n) => n.id === hash);
|
||||
if (note) editingNote = note;
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -26,9 +44,10 @@
|
||||
<div class="mx-auto mb-6 max-w-xl">
|
||||
<button
|
||||
onclick={() => (showNewNote = true)}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-left text-sm text-gray-500 shadow-sm transition-shadow hover:shadow-md"
|
||||
class="flex w-full items-center gap-3 rounded-sm border border-dashed border-[var(--border-subtle)] bg-[var(--bg-surface)] px-4 py-3 text-left text-sm text-[var(--text-muted)] transition-colors hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||
data-testid="new-note-btn"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
Add a crumb...
|
||||
</button>
|
||||
</div>
|
||||
@@ -39,9 +58,9 @@
|
||||
</div>
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-600">Archive</h2>
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
{:else if $currentFilter === 'trashed'}
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-600">Trash</h2>
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Trash</h2>
|
||||
{/if}
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
@@ -56,12 +75,10 @@
|
||||
onEdit={openEditor}
|
||||
/>
|
||||
|
||||
{#if $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<svg class="mb-4 h-24 w-24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p class="text-lg">
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
No crumbs in trash
|
||||
{:else if $currentFilter === 'archived'}
|
||||
@@ -69,7 +86,7 @@
|
||||
{:else if $selectedTag}
|
||||
No crumbs with tag #{$selectedTag}
|
||||
{:else}
|
||||
Your crumbs will appear here
|
||||
No crumbs yet
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)] text-[var(--text)]">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errorMsg = data.message || 'Invalid password';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Enter your password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleLogin}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="login-btn"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSetup(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
|
||||
if (password.length < 8) {
|
||||
errorMsg = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMsg = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errorMsg = data.message || 'Setup failed';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Set up your password to get started</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSetup}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded-sm border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="mb-3 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm password"
|
||||
class="mb-4 w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] px-4 py-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||
data-testid="confirm-password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-sm bg-[var(--primary)] py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:opacity-50"
|
||||
data-testid="setup-btn"
|
||||
>
|
||||
{loading ? 'Setting up...' : 'Set password & start'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
|
||||
{page.status}
|
||||
</h1>
|
||||
<p class="mt-4 text-sm text-[var(--text-muted)]">
|
||||
{page.error?.message ?? 'Something went wrong'}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-6 inline-block rounded-sm bg-[var(--primary)] px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)]"
|
||||
>
|
||||
Back to notes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,32 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from '$lib/components/Layout/Header.svelte';
|
||||
import Sidebar from '$lib/components/Layout/Sidebar.svelte';
|
||||
import Toast from '$lib/components/Layout/Toast.svelte';
|
||||
import { loadNotes } from '$lib/stores/notes.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
loadNotes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-white text-gray-900">
|
||||
<Header onMenuToggle={() => (sidebarOpen = !sidebarOpen)} />
|
||||
<Sidebar open={sidebarOpen} />
|
||||
|
||||
<main class="flex-1 pt-4 transition-all lg:ml-64">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="pb-4 pt-8 text-center text-xs text-gray-400 lg:ml-64">
|
||||
Crumbs by Bretzel — made with 🥨 in Strasbourg
|
||||
</footer>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
{@render children()}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const filter = url.searchParams.get('filter') || 'all';
|
||||
@@ -21,23 +22,18 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
const result = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||
.all();
|
||||
|
||||
// Fetch tags for each note
|
||||
const notesWithTags = await Promise.all(
|
||||
result.map(async (note) => {
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, note.id));
|
||||
return { ...note, tags: tagRows.map((t) => t.name) };
|
||||
})
|
||||
);
|
||||
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
|
||||
const notesWithTags = result.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(notesWithTags);
|
||||
};
|
||||
@@ -63,27 +59,13 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
version: 1
|
||||
};
|
||||
|
||||
await db.insert(notes).values(newNote);
|
||||
|
||||
// Extract and save tags
|
||||
const content = `${newNote.title} ${newNote.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
await syncNoteTags(id, extractedTags);
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.insert(notes).values(newNote).run();
|
||||
});
|
||||
syncNoteTags(id, extractedTags);
|
||||
|
||||
return json({ ...newNote, tags: extractedTags }, { status: 201 });
|
||||
};
|
||||
|
||||
async function syncNoteTags(noteId: string, tagNames: string[]) {
|
||||
// Remove existing tag associations
|
||||
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
|
||||
|
||||
for (const name of tagNames) {
|
||||
// Upsert tag
|
||||
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
|
||||
if (!tagRow) {
|
||||
const result = await db.insert(tags).values({ name }).returning();
|
||||
tagRow = result[0];
|
||||
}
|
||||
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
|
||||
import { notes } from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { extractTags } from '$lib/utils/tags.js';
|
||||
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const note = await db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
|
||||
if (!note) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, note.id));
|
||||
const tagMap = fetchTagsForNotes([note.id]);
|
||||
|
||||
return json({ ...note, tags: tagRows.map((t) => t.name) });
|
||||
return json({ ...note, tags: tagMap.get(note.id) ?? [] });
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
const body = await request.json();
|
||||
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
@@ -47,46 +44,28 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
|
||||
|
||||
await db.update(notes).set(updates).where(eq(notes.id, params.id));
|
||||
db.update(notes).set(updates).where(eq(notes.id, params.id)).run();
|
||||
|
||||
const updated = await db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
const updated = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
|
||||
// Re-sync tags if content or title changed
|
||||
if (body.title !== undefined || body.content !== undefined) {
|
||||
const content = `${updated!.title} ${updated!.content}`;
|
||||
const extractedTags = extractTags(content);
|
||||
await syncNoteTags(params.id, extractedTags);
|
||||
syncNoteTags(params.id, extractedTags);
|
||||
}
|
||||
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, params.id));
|
||||
const tagMap = fetchTagsForNotes([params.id]);
|
||||
|
||||
return json({ ...updated, tags: tagRows.map((t) => t.name) });
|
||||
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
|
||||
|
||||
if (!existing) {
|
||||
throw error(404, 'Note not found');
|
||||
}
|
||||
|
||||
await db.delete(notes).where(eq(notes.id, params.id));
|
||||
db.delete(notes).where(eq(notes.id, params.id)).run();
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
async function syncNoteTags(noteId: string, tagNames: string[]) {
|
||||
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
|
||||
|
||||
for (const name of tagNames) {
|
||||
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
|
||||
if (!tagRow) {
|
||||
const result = await db.insert(tags).values({ name }).returning();
|
||||
tagRow = result[0];
|
||||
}
|
||||
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
|
||||
import { and, eq, like, or } from 'drizzle-orm';
|
||||
import { and, eq, like, or, inArray } from 'drizzle-orm';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const query = url.searchParams.get('q')?.trim();
|
||||
@@ -13,7 +14,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
const results = await db
|
||||
const results = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
@@ -21,45 +22,37 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
)
|
||||
.all();
|
||||
|
||||
// Also search by tag name
|
||||
const tagResults = await db
|
||||
const tagResults = db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(like(tags.name, pattern));
|
||||
.where(like(tags.name, pattern))
|
||||
.all();
|
||||
|
||||
const tagNoteIds = new Set(tagResults.map((r) => r.noteId));
|
||||
const allNoteIds = new Set(results.map((n) => n.id));
|
||||
const resultIds = new Set(results.map((n) => n.id));
|
||||
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
|
||||
(id) => !resultIds.has(id)
|
||||
);
|
||||
|
||||
// Fetch tag-matched notes not already in results
|
||||
const extraNoteIds = [...tagNoteIds].filter((id) => !allNoteIds.has(id));
|
||||
let extraNotes: typeof results = [];
|
||||
if (extraNoteIds.length > 0) {
|
||||
for (const id of extraNoteIds) {
|
||||
const note = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), eq(notes.trashed, false)))
|
||||
.get();
|
||||
if (note) extraNotes.push(note);
|
||||
}
|
||||
extraNotes = db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
|
||||
.all();
|
||||
}
|
||||
|
||||
const combined = [...results, ...extraNotes];
|
||||
|
||||
// Attach tags to each note
|
||||
const withTags = await Promise.all(
|
||||
combined.map(async (note) => {
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, note.id));
|
||||
return { ...note, tags: tagRows.map((t) => t.name) };
|
||||
})
|
||||
);
|
||||
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
|
||||
const withTags = combined.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(withTags);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { changes } = await request.json();
|
||||
@@ -16,5 +17,12 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
const changes = await getChangesSince(since);
|
||||
return json(changes);
|
||||
|
||||
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(changesWithTags);
|
||||
};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let password = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
goto('/');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errorMsg = data.message || 'Invalid password';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<h1 class="text-2xl font-bold text-gray-800">Crumbs</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter your password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleLogin}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
|
||||
data-testid="login-btn"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let errorMsg = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSetup(e: Event) {
|
||||
e.preventDefault();
|
||||
errorMsg = '';
|
||||
|
||||
if (password.length < 8) {
|
||||
errorMsg = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMsg = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
goto('/');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
errorMsg = data.message || 'Setup failed';
|
||||
}
|
||||
} catch {
|
||||
errorMsg = 'Connection error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Setup - Crumbs</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-md">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<h1 class="text-2xl font-bold text-gray-800">Welcome to Crumbs</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Set up your password to get started</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSetup}>
|
||||
{#if errorMsg}
|
||||
<div class="mb-4 rounded bg-red-50 p-3 text-sm text-red-600" data-testid="error-message">
|
||||
{errorMsg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password (min 8 characters)"
|
||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
|
||||
data-testid="password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm password"
|
||||
class="mb-4 w-full rounded-lg border border-gray-300 px-4 py-3 text-sm outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
|
||||
data-testid="confirm-password-input"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-amber-500 py-3 text-sm font-medium text-white transition-colors hover:bg-amber-600 disabled:opacity-50"
|
||||
data-testid="setup-btn"
|
||||
>
|
||||
{loading ? 'Setting up...' : 'Set password & start'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "Crumbs by Bretzel",
|
||||
"short_name": "Crumbs",
|
||||
"description": "A self-hostable, offline-first note-taking app by Bretzel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#202124",
|
||||
"theme_color": "#f59e0b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Crumbs by Bretzel",
|
||||
"short_name": "Crumbs",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#f9f4ea",
|
||||
"background_color": "#f9f4ea",
|
||||
"display": "standalone"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user