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
+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>