Initial commit

This commit is contained in:
Bogdan
2026-06-15 09:41:14 +03:00
commit 5f389bee0f
105 changed files with 11511 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
:root {
--font-body: 'IBM Plex Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--font-size-body: 14px;
--font-size-editor: 14px;
color-scheme: dark;
/* surfaces & lines */
--bg: #090b10;
--bg-sidebar: rgba(0, 0, 0, 0.15);
--bg-surface: rgba(255, 255, 255, 0.05);
--bg-surface-hover: rgba(255, 255, 255, 0.07);
--bg-card: rgba(255, 255, 255, 0.035);
--bg-card-hover: rgba(255, 255, 255, 0.055);
--bg-editor: rgba(255, 255, 255, 0.03);
--bg-code: transparent;
--bg-modal: #0f1117;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.15);
--preview-border: transparent;
/* text */
--text: #e2e8f0;
--text-secondary: rgba(255, 255, 255, 0.65);
--text-muted: rgba(255, 255, 255, 0.4);
--text-faint: rgba(255, 255, 255, 0.25);
/* accent & state */
--accent: #9d7dea;
--accent-strong: #9d7dea;
--accent-contrast: #ffffff;
--accent-tint: rgba(157, 125, 234, 0.15);
--accent-tint-strong: rgba(157, 125, 234, 0.22);
--accent-border: rgba(157, 125, 234, 0.35);
/* semantic */
--success: #c3e88d;
--danger: #ef4444;
/* syntax */
--syntax-keyword: #c792ea;
--syntax-type: #ffcb6b;
--syntax-func: #82aaff;
--syntax-string: #c3e88d;
--syntax-comment: #5c6370;
--syntax-number: #f78c6c;
--syntax-operator: #89ddff;
--syntax-tag: #f07178;
--syntax-attr: #80cbc4;
/* editor chrome */
--cm-selection: rgba(157, 125, 234, 0.25);
/* layout (Dark) */
--radius-card: 8px;
--radius-control: 6px;
--radius-chip: 4px;
--card-pad: 0.65rem 0.9rem;
--list-gap: 0.4rem;
--list-pad: 0.6rem 0.75rem;
--panel-pad: 1.25rem;
--preview-pad: 0;
}
[data-theme='light'] {
color-scheme: light;
--bg: #ffffff;
--bg-sidebar: #f9fafb;
--bg-surface: #ffffff;
--bg-surface-hover: #f9fafb;
--bg-card: #ffffff;
--bg-card-hover: #f9fafb;
--bg-editor: #f9fafb;
--bg-code: #f9fafb;
--bg-modal: #ffffff;
--border: #e5e7eb;
--border-strong: #d1d5db;
--preview-border: #e5e7eb;
--text: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--accent: #6d5dfc;
--accent-strong: #6d5dfc;
--accent-contrast: #ffffff;
--accent-tint: rgba(109, 93, 252, 0.1);
--accent-tint-strong: rgba(109, 93, 252, 0.18);
--accent-border: #6d5dfc;
--success: #16a34a;
--danger: #dc2626;
--syntax-keyword: #2563eb;
--syntax-type: #0891b2;
--syntax-func: #7c3aed;
--syntax-string: #16a34a;
--syntax-comment: #9ca3af;
--syntax-number: #dc2626;
--syntax-operator: #475569;
--syntax-tag: #be123c;
--syntax-attr: #0e7490;
--cm-selection: rgba(109, 93, 252, 0.2);
}
[data-theme='nord'] {
color-scheme: dark;
--bg: #2e3440;
--bg-sidebar: #2e3440;
--bg-surface: #3b4252;
--bg-surface-hover: #434c5e;
--bg-card: #3b4252;
--bg-card-hover: #3b4252;
--bg-editor: #3b4252;
--bg-code: #2e3440;
--bg-modal: #2e3440;
--border: #434c5e;
--border-strong: #4c566a;
--preview-border: #434c5e;
--text: #eceff4;
--text-secondary: #d8dee9;
--text-muted: #9aa5b8;
--text-faint: #828ea6;
--accent: #88c0d0;
--accent-strong: #5e81ac;
--accent-contrast: #eceff4;
--accent-tint: rgba(136, 192, 208, 0.1);
--accent-tint-strong: rgba(136, 192, 208, 0.2);
--accent-border: #88c0d0;
--success: #a3be8c;
--danger: #bf616a;
--syntax-keyword: #81a1c1;
--syntax-type: #8fbcbb;
--syntax-func: #88c0d0;
--syntax-string: #a3be8c;
--syntax-comment: #616e88;
--syntax-number: #b48ead;
--syntax-operator: #81a1c1;
--syntax-tag: #81a1c1;
--syntax-attr: #8fbcbb;
--cm-selection: rgba(136, 192, 208, 0.25);
}
/* Shared layout for the new (spacious) themes */
[data-theme='light'],
[data-theme='nord'] {
--radius-card: 12px;
--radius-control: 8px;
--radius-chip: 6px;
--card-pad: 1rem;
--list-gap: 1rem;
--list-pad: 1.5rem;
--panel-pad: 1.5rem;
--preview-pad: 0.5rem 0.6rem;
}
html,
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: var(--font-size-body);
margin: 0;
padding: 0;
overflow: hidden;
}
* {
box-sizing: border-box;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* Custom scrollbars */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
/* highlight.js tokens for static card previews */
.hljs-keyword, .hljs-built_in { color: var(--syntax-keyword); }
.hljs-string, .hljs-meta .hljs-string { color: var(--syntax-string); }
.hljs-comment { color: var(--syntax-comment); font-style: italic; }
.hljs-number, .hljs-literal { color: var(--syntax-number); }
.hljs-title, .hljs-title.function_ { color: var(--syntax-func); }
.hljs-type, .hljs-title.class_ { color: var(--syntax-type); }
.hljs-attr, .hljs-property { color: var(--syntax-attr); }
.hljs-tag, .hljs-name { color: var(--syntax-tag); }
.hljs-symbol, .hljs-operator, .hljs-punctuation { color: var(--syntax-operator); }
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Faro</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
import { invoke } from '@tauri-apps/api/core';
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from './types';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
export async function createSnippet(input: CreateSnippetInput): Promise<Snippet> {
return invoke('create_snippet', {
title: input.title,
content: input.content,
contentType: input.content_type,
tags: input.tags,
});
}
export async function listSnippets(tagFilter?: string): Promise<Snippet[]> {
return invoke('list_snippets', { tagFilter: tagFilter ?? null });
}
export async function getSnippet(id: number): Promise<Snippet> {
return invoke('get_snippet', { id });
}
export async function updateSnippet(input: UpdateSnippetInput): Promise<Snippet> {
return invoke('update_snippet', {
id: input.id,
title: input.title,
content: input.content,
contentType: input.content_type,
tags: input.tags,
});
}
export async function deleteSnippet(id: number): Promise<void> {
return invoke('delete_snippet', { id });
}
export async function listTags(): Promise<Tag[]> {
return invoke('list_tags');
}
export async function searchSnippets(query: string): Promise<Snippet[]> {
return invoke('search_snippets', { query });
}
export async function listSnippetsByPeriod(period: TimePeriod): Promise<Snippet[]> {
return invoke('list_snippets_by_period', { period });
}
export async function updateHotkey(hotkey: string): Promise<void> {
return invoke('update_hotkey', { hotkey });
}
export async function recordCopy(id: number): Promise<void> {
return invoke('record_copy', { id });
}
export async function copyToClipboard(text: string): Promise<void> {
return writeText(text);
}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { untrack } from 'svelte';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import type { ContentType } from '$lib/types';
import { settings } from '$lib/stores/settings.svelte';
import { fontTheme, highlightExtension, resolveLanguage, darkFlag } from '$lib/editor';
let { value = $bindable(''), contentType, language = null, onblur }: {
value: string;
contentType: ContentType;
language?: string | null;
onblur?: () => void;
} = $props();
let container: HTMLDivElement;
let view: EditorView | null = null;
const fontCompartment = new Compartment();
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
// Mount the editor once the container exists. value/contentType are read
// untracked so typing or type changes don't tear down and rebuild the editor —
// those updates are handled by the dedicated effects below.
$effect(() => {
if (!container) return;
const initialDoc = untrack(() => value);
const initialType = untrack(() => contentType);
const state = EditorState.create({
doc: initialDoc,
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
EditorState.tabSize.of(2),
highlightExtension(),
fontCompartment.of(fontTheme(initialType)),
themeCompartment.of(darkFlag(settings.theme)),
langCompartment.of([]),
EditorView.updateListener.of((u) => {
if (u.docChanged) value = u.state.doc.toString();
}),
EditorView.domEventHandlers({
blur: () => {
onblur?.();
return false;
},
}),
],
});
view = new EditorView({ state, parent: container });
return () => {
view?.destroy();
view = null;
};
});
// Push external value changes into the editor without clobbering edits.
$effect(() => {
const v = value;
if (view && v !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: v } });
}
});
// Reconfigure font when the content type changes.
$effect(() => {
const ct = contentType;
if (view) view.dispatch({ effects: fontCompartment.reconfigure(fontTheme(ct)) });
});
// Reconfigure the light/dark base flag when the theme changes.
$effect(() => {
const theme = settings.theme;
if (view) view.dispatch({ effects: themeCompartment.reconfigure(darkFlag(theme)) });
});
// Reconfigure grammar (async) when type/language change.
$effect(() => {
const ct = contentType;
const lang = language;
let cancelled = false;
resolveLanguage(ct, lang).then((ext) => {
if (!cancelled && view) {
view.dispatch({ effects: langCompartment.reconfigure(ext ?? []) });
}
});
return () => {
cancelled = true;
};
});
</script>
<div class="editor" bind:this={container}></div>
<style>
.editor {
flex: 1;
min-height: 0;
height: 100%;
background: var(--bg-editor);
border: 1px solid var(--border);
border-radius: var(--radius-control);
overflow: hidden;
}
.editor :global(.cm-editor) {
height: 100%;
}
.editor :global(.cm-editor.cm-focused) {
border-color: var(--accent-border);
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { Search, X } from 'lucide-svelte';
let { value, onchange, searchInput = $bindable() }: {
value: string;
onchange: (q: string) => void;
searchInput?: HTMLInputElement;
} = $props();
let inputValue = $state(value);
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { inputValue = value; });
$effect(() => { return () => { if (timer) clearTimeout(timer); }; });
function handleInput(e: Event) {
const v = (e.target as HTMLInputElement).value;
inputValue = v;
if (timer) clearTimeout(timer);
timer = setTimeout(() => onchange(v), 250);
}
function clear() {
inputValue = '';
if (timer) clearTimeout(timer);
onchange('');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { clear(); (e.target as HTMLInputElement).blur(); }
}
</script>
<div class="search-bar">
<span class="icon-search"><Search size={14} strokeWidth={2} /></span>
<input
type="search"
placeholder="Search snippets…"
value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
bind:this={searchInput}
/>
{#if inputValue}
<button class="clear-btn" onclick={clear}><X size={14} strokeWidth={2} /></button>
{/if}
</div>
<style>
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.icon-search {
position: absolute;
left: 0.6rem;
display: flex;
align-items: center;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
input {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 2rem 0.4rem 2.1rem;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
input:focus {
border-color: var(--accent-border);
background: var(--bg-surface-hover);
}
input::placeholder { color: var(--text-muted); }
input::-webkit-search-cancel-button { display: none; }
.clear-btn {
position: absolute;
right: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
padding: 0.15rem;
cursor: pointer;
border-radius: var(--radius-chip);
transition: color 0.1s;
}
.clear-btn:hover { color: var(--text); }
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { settings } from '$lib/stores/settings.svelte';
let { open, onclose }: { open: boolean; onclose: () => void } = $props();
let draftHotkey = $state(settings.hotkey);
let draftFontSize = $state(settings.fontSize);
let draftStayOpen = $state(settings.quickNoteStayOpen);
let draftTheme = $state(settings.theme);
let saving = $state(false);
let error = $state<string | null>(null);
let overlayEl = $state<HTMLDivElement | null>(null);
// Sync draft from store when modal opens; focus overlay for keyboard handling
$effect(() => {
if (open) {
draftHotkey = settings.hotkey;
draftFontSize = settings.fontSize;
draftStayOpen = settings.quickNoteStayOpen;
draftTheme = settings.theme;
error = null;
setTimeout(() => overlayEl?.focus(), 0);
}
});
// Live font-size preview
$effect(() => {
if (open) {
document.documentElement.style.setProperty('--font-size-editor', `${draftFontSize}px`);
}
});
// Live theme preview
$effect(() => {
if (open) {
document.documentElement.setAttribute('data-theme', draftTheme);
}
});
function handleCancel() {
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
document.documentElement.setAttribute('data-theme', settings.theme);
onclose();
}
async function handleSave() {
saving = true;
error = null;
try {
await settings.save({
hotkey: draftHotkey,
fontSize: draftFontSize,
quickNoteStayOpen: draftStayOpen,
theme: draftTheme,
});
onclose();
} catch (e) {
error = String(e);
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
document.documentElement.setAttribute('data-theme', settings.theme);
draftFontSize = settings.fontSize;
draftTheme = settings.theme;
} finally {
saving = false;
}
}
</script>
{#if open}
<div
class="overlay"
role="dialog"
aria-modal="true"
aria-label="Settings"
tabindex="-1"
bind:this={overlayEl}
onkeydown={(e) => { if (e.key === 'Escape') handleCancel(); }}
>
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Settings</h2>
</div>
<div class="modal-body">
<div class="setting-row">
<label for="hotkey-input" class="setting-label">Global hotkey</label>
<input
id="hotkey-input"
type="text"
bind:value={draftHotkey}
class="setting-input"
placeholder="e.g. Ctrl+Shift+Space"
/>
{#if error}
<span class="field-error">{error}</span>
{/if}
</div>
<div class="setting-row">
<label for="font-size-input" class="setting-label">Editor font size</label>
<div class="font-size-control">
<input
id="font-size-input"
type="number"
bind:value={draftFontSize}
min="10"
max="24"
step="1"
class="setting-input number-input"
/>
<span class="font-size-unit">px</span>
</div>
</div>
<div class="setting-row">
<label for="stay-open-toggle" class="setting-label">Quick Note stay open after save</label>
<input
id="stay-open-toggle"
type="checkbox"
bind:checked={draftStayOpen}
class="setting-checkbox"
/>
</div>
<div class="setting-row">
<span class="setting-label">Theme</span>
<div class="theme-buttons" role="group" aria-label="Theme">
{#each (['dark', 'light', 'nord'] as const) as t (t)}
<button
type="button"
class="theme-btn"
class:active={draftTheme === t}
onclick={() => (draftTheme = t)}
>{t}</button>
{/each}
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={handleCancel} disabled={saving}>Cancel</button>
<button class="btn-primary" onclick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: 8px;
width: 420px;
max-width: calc(100vw - 2rem);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 1rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.modal-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.modal-body {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.setting-label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.setting-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-family: var(--font-body);
font-size: 13px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.setting-input:focus { border-color: var(--accent-border); }
.number-input { width: 80px; }
.font-size-control { display: flex; align-items: center; gap: 0.4rem; }
.font-size-unit { font-size: 12px; color: var(--text-muted); }
.setting-checkbox { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
.theme-buttons { display: flex; gap: 0.35rem; }
.theme-btn {
flex: 1;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
text-transform: capitalize;
padding: 0.3rem 0.5rem;
cursor: pointer;
}
.theme-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.field-error { font-size: 11px; color: var(--danger); }
.modal-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: 4px;
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.35; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--border-strong);
border-radius: 4px;
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-secondary:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text); }
.btn-secondary:disabled { opacity: 0.35; cursor: default; }
</style>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
import { Code2, Terminal, FileText, Copy, Check } from 'lucide-svelte';
import type { Snippet } from '$lib/types';
import { copyToClipboard, recordCopy } from '$lib/api';
import { highlightPreviewHtml } from '$lib/highlight';
let { snippet, selected = false, onselect }: {
snippet: Snippet;
selected?: boolean;
onselect?: (id: number) => void;
} = $props();
const previewLines = $derived(snippet.content.split('\n').slice(0, 2).join('\n'));
const previewLang = $derived(
snippet.content_type === 'code'
? (snippet.tags.find(t => t.source === 'system')?.name ?? null)
: snippet.content_type === 'cli'
? 'bash'
: null,
);
const previewHtml = $derived(highlightPreviewHtml(previewLines, previewLang));
const previewFont = $derived(
snippet.content_type === 'text' ? 'var(--font-body)' : 'var(--font-mono)',
);
let copied = $state(false);
async function copyContent(e: MouseEvent) {
e.stopPropagation();
try {
await copyToClipboard(snippet.content);
copied = true;
setTimeout(() => { copied = false; }, 1500);
recordCopy(snippet.id).catch(() => {});
} catch { /* clipboard unavailable */ }
}
</script>
<div
class="card"
class:selected
role="button"
tabindex="0"
data-snippet-id={snippet.id}
onclick={() => onselect?.(snippet.id)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onselect?.(snippet.id))}
>
<div class="header">
<span class="type-icon {snippet.content_type}">
{#if snippet.content_type === 'code'}
<Code2 size={13} strokeWidth={2} />
{:else if snippet.content_type === 'cli'}
<Terminal size={13} strokeWidth={2} />
{:else}
<FileText size={13} strokeWidth={2} />
{/if}
</span>
<span class="title">{snippet.title || 'Untitled'}</span>
<button class="copy-btn" onclick={copyContent}>
{#if copied}
<Check size={12} strokeWidth={2.5} />
{:else}
<Copy size={12} strokeWidth={2} />
{/if}
</button>
</div>
<pre class="preview" style:font-family={previewFont}>{@html previewHtml}</pre>
{#if snippet.tags.some(t => t.source !== 'suppressed')}
<div class="tags">
{#each snippet.tags.filter(t => t.source !== 'suppressed') as tag (tag.id)}
<span class="tag" class:system={tag.source === 'system'}>{tag.name}</span>
{/each}
</div>
{/if}
</div>
<style>
.card {
--icon-col: calc(20px + 0.5rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: var(--card-pad);
cursor: pointer;
user-select: none;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.card:hover {
background: var(--bg-card-hover);
border-color: var(--border-strong);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.card.selected {
background: var(--accent-tint);
border-color: var(--accent-border);
box-shadow: 0 2px 8px var(--accent-tint);
}
.header {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.type-icon {
display: flex;
align-items: center;
width: 20px;
flex-shrink: 0;
justify-content: center;
}
.type-icon.code { color: var(--accent); }
.type-icon.cli { color: var(--success); }
.type-icon.text { color: var(--text-muted); }
.title {
flex: 1;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
width: 22px;
height: 22px;
border-radius: var(--radius-chip);
cursor: pointer;
opacity: 0;
transition: opacity 0.1s, background 0.1s, color 0.1s;
}
.card:hover .copy-btn,
.card.selected .copy-btn,
.copy-btn:focus { opacity: 1; }
.copy-btn:hover { background: var(--bg-surface-hover); color: var(--text); }
.preview {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 0.35rem var(--icon-col);
background: var(--bg-code);
border: 1px solid var(--preview-border);
border-radius: var(--radius-control);
padding: var(--preview-pad);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
word-break: break-all;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-left: var(--icon-col);
}
.tag {
font-size: 11px;
padding: 1px 6px;
border-radius: var(--radius-chip);
background: var(--accent-tint);
color: var(--accent);
}
.tag.system {
background: var(--bg-surface-hover);
color: var(--text-faint);
font-style: italic;
}
</style>
+388
View File
@@ -0,0 +1,388 @@
<script lang="ts">
import type { Snippet, ContentType, CreateSnippetInput, UpdateSnippetInput } from '$lib/types';
import { detectSnippet } from '$lib/highlight';
import CodeEditor from './CodeEditor.svelte';
import { LANGUAGE_OPTIONS } from '$lib/languages';
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
snippet?: Snippet | null;
mode: 'idle' | 'creating' | 'editing';
onSave: (input: CreateSnippetInput | UpdateSnippetInput) => void;
onCancel?: () => void;
onDelete?: () => void;
dirty?: boolean;
} = $props();
let draftTitle = $state('');
let draftContent = $state('');
let draftType = $state<ContentType>('text');
let draftTags = $state('');
let draftSystemTags = $state<string[]>([]);
let draftSuppressedTags = $state<string[]>([]);
let typeManuallySet = $state(false);
let systemTagsManuallySet = $state(false);
// Re-initialize draft whenever mode or snippet changes.
$effect(() => {
if (mode === 'editing' && snippet) {
draftTitle = snippet.title;
draftContent = snippet.content;
draftType = snippet.content_type;
draftTags = snippet.tags
.filter(t => t.source === 'user')
.map(t => t.name)
.join(', ');
draftSystemTags = snippet.tags
.filter(t => t.source === 'system')
.map(t => t.name);
draftSuppressedTags = snippet.tags
.filter(t => t.source === 'suppressed')
.map(t => t.name);
typeManuallySet = false;
systemTagsManuallySet = false;
dirty = false;
} else if (mode === 'creating') {
draftTitle = '';
draftContent = '';
draftType = 'text';
draftTags = '';
draftSystemTags = [];
draftSuppressedTags = [];
typeManuallySet = false;
systemTagsManuallySet = false;
dirty = false;
}
});
function removeSystemTag(tag: string) {
draftSystemTags = draftSystemTags.filter(t => t !== tag);
if (!draftSuppressedTags.includes(tag)) {
draftSuppressedTags = [...draftSuppressedTags, tag];
}
systemTagsManuallySet = true;
dirty = true;
}
const currentLanguage = $derived(draftSystemTags[0] ?? null);
function setLanguage(lang: string) {
draftSystemTags = lang ? [lang] : [];
systemTagsManuallySet = true;
dirty = true;
}
$effect(() => {
const content = draftContent;
if (mode === 'editing' && snippet && content !== snippet.content) dirty = true;
if (mode === 'creating' && content.trim()) dirty = true;
});
function runDetection() {
if (!draftContent.trim()) {
if (!systemTagsManuallySet && draftSystemTags.length > 0) dirty = true;
if (!systemTagsManuallySet) draftSystemTags = [];
return;
}
const { content_type, language } = detectSnippet(draftContent);
const newSystemTags = (language ? [language] : [])
.filter(t => !draftSuppressedTags.includes(t));
if (!typeManuallySet) {
if (content_type !== draftType) dirty = true;
draftType = content_type;
}
if (!systemTagsManuallySet) {
if (newSystemTags.join() !== draftSystemTags.join()) dirty = true;
draftSystemTags = newSystemTags;
}
}
function handleSave() {
runDetection();
const userTags = draftTags
.split(',')
.map(t => t.trim())
.filter(Boolean)
.map(name => ({ name, source: 'user' }));
const sysTags = draftSystemTags.map(name => ({ name, source: 'system' }));
const suppressedTags = draftSuppressedTags.map(name => ({ name, source: 'suppressed' }));
const tags = [...userTags, ...sysTags, ...suppressedTags];
if (mode === 'editing' && snippet) {
onSave({
id: snippet.id,
title: draftTitle,
content: draftContent,
content_type: draftType,
tags,
} as UpdateSnippetInput);
} else {
onSave({
title: draftTitle,
content: draftContent,
content_type: draftType,
tags,
} as CreateSnippetInput);
}
}
</script>
{#if mode === 'idle'}
<div class="placeholder">
<span>Select a snippet or create a new one</span>
</div>
{:else}
<div class="detail" onkeydown={(e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); handleSave(); } }}>
<input
id="title-input"
type="text"
bind:value={draftTitle}
oninput={() => (dirty = true)}
placeholder="Untitled snippet"
class="title-input"
/>
<div class="field">
<label for="type-buttons">Type</label>
<div class="type-row">
<div class="type-buttons" id="type-buttons" role="group">
{#each (['code', 'cli', 'text'] as ContentType[]) as t (t)}
<button
class="type-btn"
class:active={draftType === t}
onclick={() => { draftType = t; typeManuallySet = true; dirty = true; }}
>{t}</button>
{/each}
</div>
{#if draftType === 'code'}
<select
class="lang-select"
value={currentLanguage ?? ''}
onchange={(e) => setLanguage((e.currentTarget as HTMLSelectElement).value)}
aria-label="Language"
>
<option value="">auto</option>
{#each LANGUAGE_OPTIONS as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{/if}
</div>
</div>
<div class="field grow">
<CodeEditor
bind:value={draftContent}
contentType={draftType}
language={currentLanguage}
onblur={runDetection}
/>
</div>
<div class="field">
<label for="tags-input">Tags</label>
{#if draftSystemTags.length > 0}
<div class="system-tags">
{#each draftSystemTags as tag (tag)}
<span class="system-tag">
{tag}
<button class="system-tag-remove" onclick={() => removeSystemTag(tag)} title="Remove">×</button>
</span>
{/each}
</div>
{/if}
<input
id="tags-input"
type="text"
bind:value={draftTags}
oninput={() => (dirty = true)}
placeholder="rust, async, cli (comma-separated)"
class="tags-input"
/>
</div>
<div class="actions">
<div class="actions-left">
{#if mode === 'editing' && onDelete}
<button class="btn-danger" onclick={onDelete}>Delete</button>
{/if}
</div>
<div class="actions-right">
{#if onCancel}
<button class="btn-secondary" onclick={onCancel}>Cancel</button>
{/if}
<button
class="btn-primary"
onclick={handleSave}
disabled={mode === 'editing' ? !dirty : !draftTitle.trim() && !draftContent.trim()}
>
{mode === 'creating' ? 'Create' : 'Save'}
</button>
</div>
</div>
</div>
{/if}
<style>
.placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-faint);
font-size: 13px;
}
.detail {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--panel-pad);
gap: 0.75rem;
overflow: hidden;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field.grow { flex: 1; min-height: 0; }
label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.title-input {
background: none;
border: none;
border-bottom: 1px solid transparent;
border-radius: 0;
color: var(--text);
font-family: var(--font-body);
font-size: 18px;
font-weight: 600;
padding: 0.1rem 0;
outline: none;
width: 100%;
transition: border-color 0.15s;
margin-bottom: 0.25rem;
}
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
.title-input:focus { border-bottom-color: var(--accent-border); }
.title-input::placeholder { color: var(--text-faint); font-weight: 400; }
.system-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
.system-tag {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: var(--accent-tint);
border: 1px solid var(--accent-border);
border-radius: var(--radius-chip);
color: var(--accent);
font-size: 11px;
padding: 0.1rem 0.35rem 0.1rem 0.45rem;
}
.system-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0.6;
padding: 0;
}
.system-tag-remove:hover { opacity: 1; }
.tags-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.tags-input:focus { border-color: var(--accent-border); }
.type-row { display: flex; align-items: center; gap: 0.5rem; }
.type-buttons { display: flex; gap: 0.35rem; }
.lang-select {
background-color: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text);
font-family: var(--font-body);
font-size: 12px;
padding: 0.2rem 0.4rem;
outline: none;
cursor: pointer;
}
.lang-select:hover { border-color: var(--border-strong); }
.lang-select:focus { border-color: var(--accent-border); }
.lang-select option {
background-color: var(--bg-surface);
color: var(--text);
}
.type-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
padding: 0.2rem 0.75rem;
cursor: pointer;
}
.type-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.actions-left { display: flex; }
.actions-right { display: flex; gap: 0.5rem; }
.btn-danger {
background: none;
border: 1px solid var(--danger);
border-radius: var(--radius-control);
color: var(--danger);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-danger:hover { border-color: var(--danger); color: var(--danger); }
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: var(--radius-control);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.35; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control);
color: var(--text-muted);
font-size: 13px;
font-family: var(--font-body);
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.btn-secondary:hover { border-color: var(--border-strong); color: var(--text); }
</style>
@@ -0,0 +1,85 @@
<script lang="ts">
import SnippetCard from '$lib/components/SnippetCard.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
import type { Snippet, TimePeriod } from '$lib/types';
let { snippets, selectedId, loading = false, searchQuery, tagFilter = null, timePeriodFilter = null, onSelect, onSearch, searchInputEl = $bindable() }: {
snippets: Snippet[];
selectedId: number | null;
loading?: boolean;
searchQuery: string;
tagFilter?: string | null;
timePeriodFilter?: TimePeriod | null;
onSelect: (id: number) => void;
onSearch: (q: string) => void;
searchInputEl?: HTMLInputElement;
} = $props();
const PERIOD_LABELS: Record<TimePeriod, string> = {
'today': 'today',
'yesterday': 'yesterday',
'this-week': 'this week',
'last-week': 'last week',
'older': 'older than two weeks',
};
const emptyMessage = $derived(
searchQuery
? 'No snippets match your search.'
: timePeriodFilter
? `No snippets from ${PERIOD_LABELS[timePeriodFilter]}.`
: tagFilter
? `No snippets tagged "${tagFilter}".`
: 'No snippets yet — create one with the button on the left.'
);
</script>
<div class="center-panel">
<div class="search-bar-wrap">
<SearchBar value={searchQuery} onchange={onSearch} bind:searchInput={searchInputEl} />
</div>
<div class="card-list">
{#if loading}
<p class="empty">Loading…</p>
{:else if snippets.length === 0}
<p class="empty">{emptyMessage}</p>
{:else}
{#each snippets as snippet (snippet.id)}
<SnippetCard
{snippet}
selected={selectedId === snippet.id}
onselect={onSelect}
/>
{/each}
{/if}
</div>
</div>
<style>
.center-panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
}
.search-bar-wrap {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.card-list {
flex: 1;
overflow-y: auto;
padding: var(--list-pad);
display: flex;
flex-direction: column;
gap: var(--list-gap);
}
.empty {
padding: 2.5rem 1.25rem;
color: var(--text-muted);
font-size: 13px;
text-align: center;
margin: 0;
}
</style>
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
import { Settings, Plus, Clock, Tag as TagIcon, Search } from 'lucide-svelte';
import type { Tag, TimePeriod } from '$lib/types';
const TIMELINE_ITEMS: { label: string; value: TimePeriod }[] = [
{ label: 'Today', value: 'today' },
{ label: 'Yesterday', value: 'yesterday' },
{ label: 'This week', value: 'this-week' },
{ label: 'Last week', value: 'last-week' },
{ label: 'Older', value: 'older' },
];
let {
tags,
activeFilter = null,
onFilterChange,
onNewSnippet,
onOpenSettings = () => {},
timePeriodFilter = null,
onTimePeriodChange = () => {},
}: {
tags: Tag[];
activeFilter?: string | null;
onFilterChange: (tag: string | null) => void;
onNewSnippet: () => void;
onOpenSettings?: () => void;
timePeriodFilter?: TimePeriod | null;
onTimePeriodChange?: (period: TimePeriod | null) => void;
} = $props();
let tagSearch = $state('');
const filteredTags = $derived(
tagSearch.trim()
? tags.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
: tags
);
</script>
<aside class="left-panel">
<!-- <div class="panel-header">
<img src="/logo.svg" alt="Faro Logo" width="64" height="64" />
<span class="brand">Faro</span>
</div> -->
<div class="panel-body">
<!-- Timeline section -->
<div class="section">
<div class="section-label"><Clock size={10} strokeWidth={2.5} />Timeline</div>
{#each TIMELINE_ITEMS as item (item.value)}
<button
class="nav-item"
class:active={timePeriodFilter === item.value}
onclick={() => onTimePeriodChange(item.value)}
>
{item.label}
</button>
{/each}
</div>
<div class="divider"></div>
<!-- Tags section -->
<div class="section tags-section">
<div class="section-label"><TagIcon size={10} strokeWidth={2.5} />Tags</div>
<div class="tag-search-wrap">
<span class="tag-search-icon"><Search size={11} strokeWidth={2} /></span>
<input
class="tag-search"
type="text"
placeholder="Filter tags…"
bind:value={tagSearch}
onkeydown={(e) => { if (e.key === 'Escape') tagSearch = ''; }}
/>
</div>
{#if !tagSearch.trim()}
<button
class="nav-item"
class:active={activeFilter === null && timePeriodFilter === null}
onclick={() => { onFilterChange(null); onTimePeriodChange(null); }}
>
All snippets
</button>
{/if}
{#each filteredTags as tag (tag.id)}
<button
class="nav-item"
class:active={activeFilter === tag.name}
onclick={() => onFilterChange(tag.name)}
>
<span class="tag-name">{tag.name}</span>
{#if tag.count}
<span class="tag-count">{tag.count}</span>
{/if}
</button>
{/each}
{#if tagSearch.trim() && filteredTags.length === 0}
<p class="tag-empty">No tags match</p>
{/if}
</div>
</div>
<div class="panel-footer">
<button class="new-btn" onclick={onNewSnippet}>
<Plus size={14} strokeWidth={2.5} />New Snippet
</button>
<button class="settings-btn" onclick={onOpenSettings} title="Settings">
<Settings size={14} strokeWidth={1.75} />
</button>
</div>
</aside>
<style>
.left-panel {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
background: var(--bg-sidebar);
}
/* .panel-header {
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
display: flex;
flex-direction: column;
align-items: center;
}
.brand {
font-size: 28px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.06em;
} */
.panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
}
.section {
padding: 0.25rem 0;
}
.tags-section {
flex: 1;
overflow-y: auto;
}
.section-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 0.4rem 1rem 0.25rem;
}
.divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
.tag-search-wrap {
position: relative;
display: flex;
align-items: center;
margin: 0.2rem 0.75rem 0.25rem;
}
.tag-search-icon {
position: absolute;
left: 0.5rem;
display: flex;
align-items: center;
color: var(--text-muted);
pointer-events: none;
}
.tag-search {
width: 100%;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 12px;
padding: 0.3rem 0.5rem 0.3rem 1.7rem;
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.tag-search:focus {
border-color: var(--accent-border);
background: var(--bg-surface-hover);
}
.tag-search::placeholder { color: var(--text-faint); }
.tag-empty {
font-size: 12px;
color: var(--text-muted);
padding: 0.5rem 1rem;
margin: 0;
}
.nav-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.35rem 1rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 14px;
font-family: var(--font-body);
text-align: left;
cursor: pointer;
border-radius: 0;
transition: background 0.1s, color 0.1s;
}
.nav-item:hover {
background: var(--bg-surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--accent-tint);
color: var(--accent);
}
.tag-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.4rem;
flex-shrink: 0;
}
.nav-item.active .tag-count {
color: var(--accent);
}
.panel-footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.new-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem;
background: var(--accent-tint);
border: 1px solid var(--accent-border);
border-radius: var(--radius-chip);
color: var(--accent);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
cursor: pointer;
transition: background 0.15s;
}
.new-btn:hover { background: var(--accent-tint-strong); }
.settings-btn {
flex-shrink: 0;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 15px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
line-height: 1;
}
.settings-btn:hover { border-color: var(--border-strong); color: var(--text); }
</style>
@@ -0,0 +1,21 @@
<script lang="ts">
import SnippetDetail from '$lib/components/SnippetDetail.svelte';
import type { Snippet, CreateSnippetInput, UpdateSnippetInput } from '$lib/types';
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
snippet?: Snippet | null;
mode: 'idle' | 'creating' | 'editing';
onSave: (input: CreateSnippetInput | UpdateSnippetInput) => void;
onCancel?: () => void;
onDelete?: () => void;
dirty?: boolean;
} = $props();
</script>
<div class="right-panel">
<SnippetDetail {snippet} {mode} {onSave} {onCancel} {onDelete} bind:dirty />
</div>
<style>
.right-panel { height: 100%; overflow: hidden; }
</style>
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { editorFontFamily } from './editor';
describe('editorFontFamily', () => {
it('uses the body font for text snippets', () => {
expect(editorFontFamily('text')).toBe('var(--font-body)');
});
it('uses the mono font for code snippets', () => {
expect(editorFontFamily('code')).toBe('var(--font-mono)');
});
it('uses the mono font for cli snippets', () => {
expect(editorFontFamily('cli')).toBe('var(--font-mono)');
});
});
+85
View File
@@ -0,0 +1,85 @@
import { EditorView } from '@codemirror/view';
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
LanguageDescription,
} from '@codemirror/language';
import { languages } from '@codemirror/language-data';
import { tags as t } from '@lezer/highlight';
import type { Extension } from '@codemirror/state';
import type { ContentType } from './types';
import { cmLanguageName } from './languages';
/** CSS font-family variable for a content type. */
export function editorFontFamily(contentType: ContentType): string {
return contentType === 'text' ? 'var(--font-body)' : 'var(--font-mono)';
}
/** Editor base theme: transparent background, font from CSS vars. */
export function fontTheme(contentType: ContentType): Extension {
const family = editorFontFamily(contentType);
return EditorView.theme(
{
'&': {
backgroundColor: 'transparent',
color: 'var(--text)',
fontSize: 'var(--font-size-editor, 14px)',
height: '100%',
},
'.cm-content': { fontFamily: family, lineHeight: '1.65', padding: '0.6rem' },
'.cm-gutters': { display: 'none' },
'.cm-scroller': { fontFamily: family, overflow: 'auto' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'var(--cm-selection)',
},
},
);
}
/** Base light/dark flag for CodeMirror built-ins, driven by the active theme. */
export function darkFlag(theme: string): Extension {
return EditorView.theme({}, { dark: theme !== 'light' });
}
const faroHighlight = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--syntax-keyword)' },
{ tag: [t.string, t.special(t.string)], color: 'var(--syntax-string)' },
{ tag: t.comment, color: 'var(--syntax-comment)', fontStyle: 'italic' },
{ tag: [t.number, t.bool, t.null], color: 'var(--syntax-number)' },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: 'var(--syntax-func)' },
{ tag: [t.typeName, t.className], color: 'var(--syntax-type)' },
{ tag: [t.operator, t.punctuation], color: 'var(--syntax-operator)' },
{ tag: t.propertyName, color: 'var(--syntax-attr)' },
{ tag: [t.tagName], color: 'var(--syntax-tag)' },
]);
/** Syntax-coloring extension; colours resolve from the active theme's CSS vars. */
export function highlightExtension(): Extension {
return syntaxHighlighting(faroHighlight);
}
/**
* Resolves a CodeMirror language extension for the given content type/language.
* Loads grammars lazily; returns null when there is nothing to highlight.
*/
export async function resolveLanguage(
contentType: ContentType,
language: string | null,
): Promise<Extension | null> {
if (contentType === 'cli') {
const { shell } = await import('@codemirror/legacy-modes/mode/shell');
return StreamLanguage.define(shell);
}
if (contentType === 'code' && language) {
const name = cmLanguageName(language);
const desc = name ? LanguageDescription.matchLanguageName(languages, name, true) : null;
if (desc) {
const support = await desc.load();
return support;
}
}
return null;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { detectSnippet, highlightPreviewHtml } from './highlight';
describe('detectSnippet', () => {
it('returns text for empty content', () => {
expect(detectSnippet('')).toEqual({ content_type: 'text', language: null });
});
it('detects a sudo/docker pipeline as cli', () => {
const result = detectSnippet(
"sudo docker logs --tail 50000 zones-module-container_01 | grep 'ERROR'",
);
expect(result.content_type).toBe('cli');
expect(result.language).toBeNull();
});
it('detects a shell prompt line as cli', () => {
const result = detectSnippet('$ npm install react');
expect(result.content_type).toBe('cli');
expect(result.language).toBeNull();
});
it('detects a multi-line shell script as cli', () => {
const result = detectSnippet('cd /tmp\nmkdir build\ntar -czf out.tgz build');
expect(result.content_type).toBe('cli');
});
it('detects a powershell pipeline as cli', () => {
const result = detectSnippet('Get-Process | Where-Object { $_.CPU -gt 10 }');
expect(result.content_type).toBe('cli');
});
it('detects a typescript function as code, not csharp', () => {
const content =
'function greet(name: string): string {\n' +
" const msg = 'Hello, ' + name;\n" +
' return msg;\n' +
'}';
const result = detectSnippet(content);
expect(result.content_type).toBe('code');
expect(['typescript', 'javascript']).toContain(result.language);
});
it('detects a json blob as code/json', () => {
const result = detectSnippet('{\n "name": "faro",\n "version": "0.1.0"\n}');
expect(result.content_type).toBe('code');
expect(result.language).toBe('json');
});
it('detects plain prose as text', () => {
const content =
'This is a note about my project. It has multiple sentences and no code at all.';
const result = detectSnippet(content);
expect(result.content_type).toBe('text');
expect(result.language).toBeNull();
});
});
describe('highlightPreviewHtml', () => {
it('escapes html when no language is given', () => {
expect(highlightPreviewHtml('<b> & "x"', null)).toBe('&lt;b&gt; &amp; &quot;x&quot;');
});
it('returns hljs-classed markup for a known language', () => {
const html = highlightPreviewHtml('const x = 1;', 'javascript');
expect(html).toContain('hljs-');
});
it('falls back to escaped text for an unknown language', () => {
expect(highlightPreviewHtml('<x>', 'cobol')).toBe('&lt;x&gt;');
});
});
+104
View File
@@ -0,0 +1,104 @@
import hljs from 'highlight.js/lib/common';
import type { ContentType } from './types';
import { SUBSET } from './languages';
// ── CLI recognition ──────────────────────────────────────────────────────────
const SHELL_COMMANDS = new Set([
'sudo', 'docker', 'kubectl', 'git', 'npm', 'pnpm', 'yarn', 'node', 'cargo',
'python', 'python3', 'pip', 'pip3', 'ssh', 'scp', 'rsync', 'curl', 'wget',
'cat', 'ls', 'cd', 'pwd', 'grep', 'egrep', 'awk', 'sed', 'rm', 'cp', 'mv',
'mkdir', 'touch', 'chmod', 'chown', 'tar', 'zip', 'unzip', 'apt', 'apt-get',
'yum', 'dnf', 'brew', 'systemctl', 'journalctl', 'service', 'make', 'cmake',
'ping', 'nslookup', 'dig', 'traceroute', 'ps', 'top', 'kill', 'killall',
'export', 'source', 'echo', 'printf', 'find', 'xargs', 'tee', 'head', 'tail',
'df', 'du', 'free', 'uname', 'whoami', 'ifconfig', 'ip', 'netstat', 'ss',
]);
// A leading shell prompt: "$ ", "PS C:\x>", "C:\x>". Bare "#"/">" are excluded
// to avoid matching comments or prose.
const PROMPT_RE = /^\s*(\$|PS[^>\n]*>|[A-Za-z]:\\[^>\n]*>)\s+/;
// Pipes and logical/redirection operators. A bare "-flag" is intentionally NOT
// matched here: it produced false-positive cli detection on prose that mentions
// command flags.
const OPERATOR_RE = /\s\|\s|&&|\|\||>>|2>&1/;
function lineLooksLikeCli(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (PROMPT_RE.test(line)) return true;
const first = trimmed.split(/\s+/)[0] ?? '';
if (SHELL_COMMANDS.has(first)) return true;
if (OPERATOR_RE.test(` ${trimmed} `) && /[A-Za-z]/.test(first)) return true;
return false;
}
/** True when the majority of non-empty lines look like shell commands. */
function looksLikeCli(content: string): boolean {
const lines = content.split('\n').filter((l) => l.trim().length > 0);
if (lines.length === 0) return false;
const hits = lines.filter(lineLooksLikeCli).length;
return hits / lines.length >= 0.5;
}
// ── Language detection ───────────────────────────────────────────────────────
const RELEVANCE_THRESHOLD = 4;
function hasTsJsMarkers(content: string): boolean {
return /\b(const|let|export)\b|=>|import\s+.*\sfrom\s/.test(content);
}
// highlightAuto leans toward C#/Java on short TS/JS; nudge it back when the
// content carries obvious TS/JS markers.
function tieBreak(content: string, language: string): string {
if ((language === 'csharp' || language === 'java') && hasTsJsMarkers(content)) {
const tsShaped = /:\s*(string|number|boolean)\b|\binterface\s|\btype\s+\w+\s*=/.test(content);
return tsShaped ? 'typescript' : 'javascript';
}
return language;
}
export function detectSnippet(
content: string,
): { content_type: ContentType; language: string | null } {
const trimmed = content.trim();
if (!trimmed) return { content_type: 'text', language: null };
if (looksLikeCli(trimmed)) {
return { content_type: 'cli', language: null };
}
const result = hljs.highlightAuto(trimmed, [...SUBSET]);
if ((result.relevance ?? 0) >= RELEVANCE_THRESHOLD && result.language) {
return { content_type: 'code', language: tieBreak(trimmed, result.language) };
}
return { content_type: 'text', language: null };
}
// ── Static preview highlighting (used by list cards) ─────────────────────────
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Returns hljs-classed HTML for a known language, otherwise escaped plain text.
* Safe to feed to {@html} hljs escapes the content it wraps.
*/
export function highlightPreviewHtml(content: string, language: string | null): string {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch {
/* fall through to escaped text */
}
}
return escapeHtml(content);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { SUBSET, cmLanguageName, LANGUAGE_OPTIONS } from './languages';
describe('languages', () => {
it('SUBSET contains the common languages', () => {
expect(SUBSET).toContain('typescript');
expect(SUBSET).toContain('csharp');
expect(SUBSET).toContain('json');
});
it('cmLanguageName maps hljs names to CodeMirror display names', () => {
expect(cmLanguageName('typescript')).toBe('TypeScript');
expect(cmLanguageName('cpp')).toBe('C++');
expect(cmLanguageName('csharp')).toBe('C#');
});
it('cmLanguageName returns null for unknown languages', () => {
expect(cmLanguageName('cobol')).toBeNull();
});
it('LANGUAGE_OPTIONS pairs hljs value with display label', () => {
const ts = LANGUAGE_OPTIONS.find(o => o.value === 'typescript');
expect(ts?.label).toBe('TypeScript');
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Canonical language ids are highlight.js language names. These are what
* detectSnippet() stores as the system tag and what hljs.highlight() expects.
* cmLanguageName() maps them to @codemirror/language-data display names for
* grammar loading.
*/
/** highlight.js language names that detection is allowed to choose from. */
export const SUBSET = [
'typescript', 'javascript', 'python', 'rust', 'go', 'java',
'c', 'cpp', 'csharp', 'ruby', 'php', 'sql', 'json', 'yaml',
'xml', 'css', 'kotlin', 'swift',
] as const;
/** hljs name -> @codemirror/language-data display name. */
const CM_NAMES: Record<string, string> = {
typescript: 'TypeScript',
javascript: 'JavaScript',
python: 'Python',
rust: 'Rust',
go: 'Go',
java: 'Java',
c: 'C',
cpp: 'C++',
csharp: 'C#',
ruby: 'Ruby',
php: 'PHP',
sql: 'SQL',
json: 'JSON',
yaml: 'YAML',
xml: 'HTML',
css: 'CSS',
kotlin: 'Kotlin',
swift: 'Swift',
};
/** Returns the CodeMirror display name for an hljs language, or null. */
export function cmLanguageName(hljsName: string): string | null {
return CM_NAMES[hljsName] ?? null;
}
/** Options for the language override dropdown: { value: hljsName, label }. */
export const LANGUAGE_OPTIONS = SUBSET.map((value) => ({
value,
label: CM_NAMES[value] ?? value,
}));
+69
View File
@@ -0,0 +1,69 @@
import { load } from '@tauri-apps/plugin-store';
import { updateHotkey } from '$lib/api';
const DEFAULTS = {
hotkey: 'Ctrl+Shift+Space',
fontSize: 13,
quickNoteStayOpen: false,
theme: 'dark',
};
const STORE_FILE = 'settings.json';
class SettingsStore {
hotkey = $state(DEFAULTS.hotkey);
fontSize = $state(DEFAULTS.fontSize);
quickNoteStayOpen = $state(DEFAULTS.quickNoteStayOpen);
theme = $state(DEFAULTS.theme);
loaded = $state(false);
async load(): Promise<void> {
const store = await load(STORE_FILE, { autoSave: false, defaults: DEFAULTS });
this.hotkey = (await store.get<string>('hotkey')) ?? DEFAULTS.hotkey;
this.fontSize = (await store.get<number>('fontSize')) ?? DEFAULTS.fontSize;
this.quickNoteStayOpen = (await store.get<boolean>('quickNoteStayOpen')) ?? DEFAULTS.quickNoteStayOpen;
this.theme = (await store.get<string>('theme')) ?? DEFAULTS.theme;
this.loaded = true;
this.applyFontSize();
this.applyTheme();
}
applyFontSize(): void {
document.documentElement.style.setProperty('--font-size-editor', `${this.fontSize}px`);
}
applyTheme(): void {
document.documentElement.setAttribute('data-theme', this.theme);
}
async save(patch: {
hotkey?: string;
fontSize?: number;
quickNoteStayOpen?: boolean;
theme?: string;
}): Promise<void> {
const store = await load(STORE_FILE, { autoSave: false, defaults: DEFAULTS });
if (patch.hotkey !== undefined && patch.hotkey !== this.hotkey) {
await updateHotkey(patch.hotkey);
this.hotkey = patch.hotkey;
await store.set('hotkey', patch.hotkey);
}
if (patch.fontSize !== undefined) {
this.fontSize = patch.fontSize;
this.applyFontSize();
await store.set('fontSize', patch.fontSize);
}
if (patch.quickNoteStayOpen !== undefined) {
this.quickNoteStayOpen = patch.quickNoteStayOpen;
await store.set('quickNoteStayOpen', patch.quickNoteStayOpen);
}
if (patch.theme !== undefined) {
this.theme = patch.theme;
this.applyTheme();
await store.set('theme', patch.theme);
}
await store.save();
}
}
export const settings = new SettingsStore();
+97
View File
@@ -0,0 +1,97 @@
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod } from '$lib/api';
class SnippetStore {
snippets = $state<Snippet[]>([]);
selectedId = $state<number | null>(null);
tags = $state<Tag[]>([]);
tagFilter = $state<string | null>(null);
searchQuery = $state<string>('');
timePeriodFilter = $state<TimePeriod | null>(null);
loading = $state(false);
error = $state<string | null>(null);
async load(): Promise<void> {
this.loading = true;
this.error = null;
try {
const [list, tagList] = await Promise.all([
this.searchQuery
? searchSnippets(this.searchQuery)
: this.timePeriodFilter
? listSnippetsByPeriod(this.timePeriodFilter)
: listSnippets(this.tagFilter ?? undefined),
listTags(),
]);
this.snippets = list;
this.tags = tagList;
} catch (e) {
this.error = String(e);
} finally {
this.loading = false;
}
}
async create(input: CreateSnippetInput): Promise<Snippet | null> {
this.error = null;
try {
const s = await createSnippet(input);
await this.load();
this.selectedId = s.id;
return s;
} catch (e) {
this.error = String(e);
return null;
}
}
async update(input: UpdateSnippetInput): Promise<void> {
this.error = null;
try {
const updated = await updateSnippet(input);
const idx = this.snippets.findIndex(x => x.id === updated.id);
if (idx >= 0) this.snippets[idx] = updated;
this.tags = await listTags();
} catch (e) {
this.error = String(e);
}
}
async remove(id: number): Promise<void> {
this.error = null;
try {
await deleteSnippet(id);
if (this.selectedId === id) this.selectedId = null;
this.snippets = this.snippets.filter(s => s.id !== id);
} catch (e) {
this.error = String(e);
}
}
select(id: number | null): void {
this.selectedId = id;
}
setTagFilter(tag: string | null): void {
this.tagFilter = tag;
this.searchQuery = '';
this.timePeriodFilter = null;
void this.load();
}
setSearch(query: string): void {
this.searchQuery = query;
this.tagFilter = null;
this.timePeriodFilter = null;
void this.load();
}
setTimePeriodFilter(period: TimePeriod | null): void {
this.timePeriodFilter = period;
this.tagFilter = null;
this.searchQuery = '';
void this.load();
}
}
export const snippets = new SnippetStore();
+48
View File
@@ -0,0 +1,48 @@
export type ContentType = 'code' | 'cli' | 'text';
export interface Tag {
id: number;
name: string;
/** 'user' | 'system' — empty string when loaded without snippet context (list_tags) */
source: string;
count?: number;
}
export type TimePeriod = 'today' | 'yesterday' | 'this-week' | 'last-week' | 'older';
export interface Snippet {
id: number;
title: string;
content: string;
content_type: ContentType;
created_at: string;
updated_at: string;
tags: Tag[];
}
export interface TagInput {
name: string;
source: string;
}
export interface CreateSnippetInput {
title: string;
content: string;
content_type: ContentType;
tags: TagInput[];
}
export interface UpdateSnippetInput {
id: number;
title: string;
content: string;
content_type: ContentType;
tags: TagInput[];
}
export interface Settings {
hotkey: string;
fontSize: number;
quickNoteStayOpen: boolean;
theme: string;
}
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
const { children } = $props();
</script>
{@render children()}
+5
View File
@@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { snippets as store } from '$lib/stores/snippets.svelte';
import { settings } from '$lib/stores/settings.svelte';
import LeftPanel from '$lib/components/layout/LeftPanel.svelte';
import CenterPanel from '$lib/components/layout/CenterPanel.svelte';
import RightPanel from '$lib/components/layout/RightPanel.svelte';
import SettingsModal from '$lib/components/SettingsModal.svelte';
import type { CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
let mode = $state<'idle' | 'creating' | 'editing'>('idle');
let formDirty = $state(false);
let settingsOpen = $state(false);
let searchInputEl = $state<HTMLInputElement | undefined>();
// Derive the currently selected Snippet object from the store
const selectedSnippet = $derived(
store.snippets.find(s => s.id === store.selectedId) ?? null
);
function isEditableTarget(e: KeyboardEvent) {
const t = e.target as HTMLElement;
return t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable;
}
function handleGlobalKeydown(e: KeyboardEvent) {
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === 'f') {
e.preventDefault();
searchInputEl?.focus();
searchInputEl?.select();
return;
}
if (ctrl && e.key === 'n') {
if (isEditableTarget(e)) return;
e.preventDefault();
handleNewSnippet();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (isEditableTarget(e)) return;
const snippets = store.snippets;
if (snippets.length === 0) return;
e.preventDefault();
const idx = snippets.findIndex(s => s.id === store.selectedId);
const next = e.key === 'ArrowDown'
? Math.min(snippets.length - 1, idx + 1)
: Math.max(0, idx - 1);
const target = snippets[next];
if (!target || target.id === store.selectedId) return;
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.select(target.id);
mode = 'editing';
requestAnimationFrame(() => {
document.querySelector(`[data-snippet-id="${target.id}"]`)?.scrollIntoView({ block: 'nearest' });
});
}
}
onMount(() => {
let unlisten: (() => void) | null = null;
listen('snippet-created', () => { store.load(); }).then(fn => {
unlisten = fn;
});
store.load();
settings.load();
window.addEventListener('keydown', handleGlobalKeydown);
return () => {
if (unlisten) unlisten();
window.removeEventListener('keydown', handleGlobalKeydown);
};
});
function handleNewSnippet() {
store.select(null);
mode = 'creating';
}
function handleSelectSnippet(id: number) {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.select(id);
mode = 'editing';
}
async function handleSave(input: CreateSnippetInput | UpdateSnippetInput) {
if (mode === 'creating') {
const s = await store.create(input as CreateSnippetInput);
if (s) mode = 'editing';
} else {
await store.update(input as UpdateSnippetInput);
}
}
function handleCancel() {
formDirty = false;
store.select(null);
mode = 'idle';
}
async function handleDelete() {
if (!store.selectedId) return;
if (!confirm('Delete this snippet? This cannot be undone.')) return;
await store.remove(store.selectedId);
if (!store.error) {
formDirty = false;
mode = 'idle';
}
}
</script>
<div class="app-layout">
<LeftPanel
tags={store.tags}
activeFilter={store.tagFilter}
timePeriodFilter={store.timePeriodFilter}
onFilterChange={(tag) => {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.setTagFilter(tag);
store.select(null);
mode = 'idle';
}}
onTimePeriodChange={(period: TimePeriod | null) => {
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
store.setTimePeriodFilter(period);
store.select(null);
mode = 'idle';
}}
onNewSnippet={handleNewSnippet}
onOpenSettings={() => { settingsOpen = true; }}
/>
<CenterPanel
snippets={store.snippets}
selectedId={store.selectedId}
loading={store.loading}
searchQuery={store.searchQuery}
tagFilter={store.tagFilter}
timePeriodFilter={store.timePeriodFilter}
onSelect={handleSelectSnippet}
onSearch={(q) => {
store.setSearch(q);
store.select(null);
mode = 'idle';
}}
bind:searchInputEl
/>
<RightPanel
snippet={selectedSnippet}
{mode}
onSave={handleSave}
onCancel={handleCancel}
onDelete={handleDelete}
bind:dirty={formDirty}
/>
</div>
<SettingsModal open={settingsOpen} onclose={() => { settingsOpen = false; }} />
{#if store.error}
<div class="error-bar">{store.error}</div>
{/if}
<style>
.app-layout {
display: grid;
grid-template-columns: 220px 1fr 360px;
grid-template-rows: 100vh;
height: 100vh;
overflow: hidden;
background: var(--bg);
}
.error-bar {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
background: var(--danger);
color: var(--accent-contrast);
font-size: 12px;
padding: 0.4rem 1rem;
border-radius: var(--radius-control);
max-width: 80vw;
z-index: 100;
}
</style>
+326
View File
@@ -0,0 +1,326 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { emit } from '@tauri-apps/api/event';
import { createSnippet } from '$lib/api';
import { detectSnippet } from '$lib/highlight';
import { settings } from '$lib/stores/settings.svelte';
import type { ContentType } from '$lib/types';
let title = $state('');
let content = $state('');
let contentType = $state<ContentType>('text');
let tags = $state('');
let systemTags = $state<string[]>([]);
let saved = $state(false);
let saving = $state(false);
let saveError = $state<string | null>(null);
const canSave = $derived(!saving && (title.trim() !== '' || content.trim() !== ''));
function runDetection() {
if (!content.trim()) { systemTags = []; return; }
const { content_type, language } = detectSnippet(content);
contentType = content_type;
systemTags = language ? [language] : [];
}
async function handleSave() {
if (saved || !canSave) return;
saveError = null;
try { runDetection(); } catch { /* detection is best-effort */ }
saving = true;
try {
if (!settings.loaded) await settings.load();
const userTags = tags
.split(',')
.map(t => t.trim())
.filter(Boolean)
.map(name => ({ name, source: 'user' }));
const sysTags = systemTags.map(name => ({ name, source: 'system' }));
await createSnippet({
title: title.trim() || 'Untitled',
content,
content_type: contentType,
tags: [...userTags, ...sysTags],
});
try { await emit('snippet-created', {}); } catch { /* main window may be gone */ }
saved = true;
if (!settings.quickNoteStayOpen) {
await getCurrentWindow().close();
} else {
setTimeout(() => {
title = '';
content = '';
tags = '';
systemTags = [];
contentType = 'text';
saved = false;
saving = false;
}, 800);
}
} catch (err) {
console.error('[quicknote] save failed:', err);
saveError = String(err);
saved = false;
saving = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') getCurrentWindow().close();
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') handleSave();
}
onMount(() => {
settings.load().then(() => {
document.documentElement.style.setProperty('--font-size-editor', `${settings.fontSize}px`);
});
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if saved}
<div class="saved-flash">
<span>Saved</span>
</div>
{:else}
<div class="page">
<input
id="title-input"
type="text"
bind:value={title}
placeholder="Untitled snippet"
class="title-input"
/>
<div class="field">
<label for="type-buttons">Type</label>
<div class="type-buttons" id="type-buttons" role="group">
{#each (['code', 'cli', 'text'] as ContentType[]) as t (t)}
<button
class="type-btn"
class:active={contentType === t}
onclick={() => { contentType = t; }}
>{t}</button>
{/each}
</div>
</div>
<div class="field grow">
<!-- svelte-ignore a11y_autofocus -->
<textarea
id="content-area"
bind:value={content}
onblur={runDetection}
placeholder="Paste your snippet here…"
class="content-area"
spellcheck="false"
autofocus
></textarea>
</div>
<div class="field">
<label for="tags-input">Tags</label>
<input
id="tags-input"
type="text"
bind:value={tags}
placeholder="rust, async, cli (comma-separated)"
class="tags-input"
/>
</div>
{#if saveError}
<div class="save-error">{saveError}</div>
{/if}
<div class="actions">
<span class="hint">Esc to discard · Ctrl+Enter to save</span>
<button
class="btn-primary"
onclick={handleSave}
disabled={!canSave}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{/if}
<style>
:global(html, body) {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
.saved-flash {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
}
.saved-flash span {
font-size: 2rem;
font-weight: 600;
color: var(--accent);
font-family: var(--font-body);
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
padding: 1.25rem;
gap: 0.75rem;
overflow: hidden;
background: var(--bg);
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.field.grow {
flex: 1;
min-height: 0;
}
label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
}
.title-input {
background: none;
border: none;
border-bottom: 1px solid transparent;
border-radius: 0;
color: var(--text);
font-family: var(--font-body);
font-size: 18px;
font-weight: 600;
padding: 0.1rem 0;
outline: none;
width: 100%;
transition: border-color 0.15s;
margin-bottom: 0.25rem;
}
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
.title-input:focus { border-bottom-color: var(--accent-border); }
.title-input::placeholder { color: var(--text-faint); font-weight: 400; }
.tags-input {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 0.4rem 0.6rem;
outline: none;
width: 100%;
}
.tags-input:focus {
border-color: var(--accent-border);
}
.type-buttons {
display: flex;
gap: 0.35rem;
}
.type-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-chip);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
padding: 0.2rem 0.75rem;
cursor: pointer;
}
.type-btn.active {
background: var(--accent-tint);
border-color: var(--accent-border);
color: var(--accent);
}
.content-area {
flex: 1;
background: var(--bg-editor);
border: 1px solid var(--border);
border-radius: var(--radius-control);
color: var(--text);
font-family: var(--font-mono);
font-size: var(--font-size-editor, 13px);
line-height: 1.65;
padding: 0.6rem;
resize: none;
outline: none;
width: 100%;
height: 100%;
}
.content-area:focus {
border-color: var(--accent-border);
}
.save-error {
background: rgba(239, 68, 68, 0.12);
border: 1px solid var(--danger);
border-radius: var(--radius-control);
color: var(--danger);
font-family: var(--font-mono);
font-size: 12px;
padding: 0.4rem 0.6rem;
white-space: pre-wrap;
word-break: break-word;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.hint {
font-size: 11px;
color: var(--text-faint);
font-family: var(--font-body);
margin-right: auto;
}
.btn-primary {
background: var(--accent-strong);
border: none;
border-radius: var(--radius-control);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
padding: 0.4rem 1.1rem;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.btn-primary:disabled {
opacity: 0.35;
cursor: default;
}
</style>