feat: add public shared note page with SSR rendering

- (share) layout group with minimal centered layout
- Server-side load function fetches shared note by token
- Read-only page renders markdown, checklists, and images
- Rate limited via IP-based limiter
- Retro parchment styling with "POWERED BY CRUMBS" footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-20 22:06:41 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 4ffa34afa0
commit 265b4266d4
3 changed files with 158 additions and 0 deletions
+7
View File
@@ -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,28 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { getSharedNote } from '$lib/server/shares-service.js';
import { checkIpRateLimit } from '$lib/server/ip-rate-limit.js';
import { db } from '$lib/server/db/index.js';
export const load: PageServerLoad = async ({ params, getClientAddress }) => {
const ip = getClientAddress();
if (!checkIpRateLimit(ip)) {
throw error(429, 'Too many requests');
}
const data = getSharedNote(db, params.token);
if (!data) {
throw error(404, 'This shared note does not exist or has been removed.');
}
return {
token: params.token,
title: data.title,
content: data.content,
checklistMode: data.checklistMode,
color: data.color,
attachments: data.attachments,
createdAt: data.createdAt,
updatedAt: data.updatedAt
};
};
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { NOTE_COLORS } from '$lib/utils/colors.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import type { NoteColor, Attachment } from '$lib/types/index.js';
interface ChecklistItem {
text: string;
checked: boolean;
}
const { data } = $props();
const noteColor = data.color as NoteColor;
const bgColor = NOTE_COLORS[noteColor]?.bg ?? NOTE_COLORS.default.bg;
const renderedContent = renderMarkdown(data.content);
const checklistItems: ChecklistItem[] = data.checklistMode
? data.content
.split('\n')
.filter((l: string) => l.trim())
.map((line: string) => ({
text: line.replace(/^- \[[ x]\] /, ''),
checked: line.startsWith('- [x] ')
}))
: [];
const activeChecklistItems = checklistItems.filter((i) => !i.checked);
const doneChecklistItems = checklistItems.filter((i) => i.checked);
const sortedChecklistItems = [...activeChecklistItems, ...doneChecklistItems];
const imageAttachments = (data.attachments as Attachment[]).filter((a) =>
a.mimeType.startsWith('image/')
);
function formatDate(date: Date | string) {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
</script>
<svelte:head>
<title>{data.title || 'Shared Note'} — Crumbs</title>
<meta name="description" content={data.content.slice(0, 160)} />
</svelte:head>
<div class="mx-4 w-full max-w-2xl py-10">
<article
class="rounded-sm border border-[var(--border-subtle)] shadow-[var(--card-shadow)] overflow-hidden"
style="background-color: {bgColor}"
data-testid="shared-note"
>
<div class="px-6 py-5">
{#if data.title}
<h1 class="mb-4 text-xl font-semibold text-[var(--text)]" data-testid="shared-note-title">
{data.title}
</h1>
{/if}
{#if data.checklistMode && checklistItems.length > 0}
<ul class="space-y-2" data-testid="shared-note-checklist">
{#each sortedChecklistItems as item}
<li
class="flex items-start gap-2 text-sm {item.checked
? 'text-[var(--text-muted)] line-through'
: 'text-[var(--text)]'}"
>
<input
type="checkbox"
checked={item.checked}
disabled
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)]"
/>
<span class="break-words min-w-0">{item.text}</span>
</li>
{/each}
</ul>
{:else if data.content}
<div
class="prose prose-sm max-w-none text-[var(--text)]"
data-testid="shared-note-content"
>
{@html renderedContent}
</div>
{/if}
{#if imageAttachments.length > 0}
<div class="mt-4 grid grid-cols-2 gap-2" data-testid="shared-note-images">
{#each imageAttachments as attachment}
<img
src="/api/shared/{data.token}/attachment/{attachment.id}"
alt={attachment.filename}
class="w-full rounded-sm border border-[var(--border-subtle)]"
loading="lazy"
/>
{/each}
</div>
{/if}
</div>
<div
class="flex items-center justify-between border-t border-[var(--border-subtle)] px-6 py-3"
>
<span class="text-xs text-[var(--text-muted)]">
{formatDate(data.updatedAt)}
</span>
</div>
</article>
<div class="mt-6 text-center">
<a
href="https://github.com/bretzel-app/crumbs"
target="_blank"
rel="noopener noreferrer"
class="font-['Press_Start_2P'] text-[8px] tracking-wider text-[var(--text-muted)] hover:text-[var(--primary)] transition-colors"
>
POWERED BY CRUMBS
</a>
</div>
</div>