mirror of
https://github.com/bogdancornescu/Faro.git
synced 2026-07-08 19:20:47 +02:00
Merge pull request #1 from bogdancornescu/bc_1
Add URL content type support and enhance snippet functionality
This commit is contained in:
@@ -47,15 +47,15 @@ Global hotkey (`Ctrl+Shift+Space` by default) opens a focused floating window
|
||||
FTS5-powered search across all snippet titles and content. Results update as you type.
|
||||
|
||||
### Auto Content Detection
|
||||
highlight.js inspects content on save and automatically classifies it as `code`, `cli`, or `text`, plus detects the specific language (Rust, Python, bash, SQL, etc.) and creates system tags. You can override the type manually.
|
||||
highlight.js inspects content on save and automatically classifies it as `code`, `cli`, `text`, or `url`, plus detects the specific language (Rust, Python, bash, SQL, etc.) and creates system tags. URL detection triggers on single-line `http`/`https`/`ftp` content. You can override the type manually.
|
||||
|
||||
### Three-Panel Layout
|
||||
|
||||
**Left panel** — timeline navigation, tag browser with inline filter, new snippet button, settings access.
|
||||
**Left panel** — timeline navigation, content type filter (Code / CLI / Text / URL chips), tag browser with inline filter scoped to the active type, new snippet button, settings access.
|
||||
|
||||
**Center panel** — full-text search bar, snippet list with type icons and 2-line previews, tag chips (user tags and auto-detected language tags are visually distinct).
|
||||
**Center panel** — full-text search bar, snippet list with type icons, copy count badge, and 2-line previews; tag chips (user tags and auto-detected language tags are visually distinct). Snippets are ordered by most-copied first, then by recency.
|
||||
|
||||
**Right panel** — snippet content, directly editable in place. Explicit save keeps accidental edits from destroying data.
|
||||
**Right panel** — snippet content, directly editable in place. Explicit save keeps accidental edits from destroying data. A **Copy** button next to the content label copies to clipboard and increments the copy counter.
|
||||
|
||||
### Tag System
|
||||
Two tag sources coexist on each snippet:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 648 KiB |
@@ -1,2 +1,4 @@
|
||||
packages:
|
||||
- '.'
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
@@ -25,7 +25,7 @@ fn load_tags(conn: &Connection, snippet_id: i64) -> Result<Vec<Tag>, AppError> {
|
||||
|
||||
fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
||||
let mut snippet = conn.query_row(
|
||||
"SELECT id, title, content, content_type, created_at, updated_at \
|
||||
"SELECT id, title, content, content_type, copy_count, created_at, updated_at \
|
||||
FROM snippet WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
@@ -35,8 +35,9 @@ fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
||||
title: r.get(1)?,
|
||||
content: r.get(2)?,
|
||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||
created_at: r.get(4)?,
|
||||
updated_at: r.get(5)?,
|
||||
copy_count: r.get(4)?,
|
||||
created_at: r.get(5)?,
|
||||
updated_at: r.get(6)?,
|
||||
tags: vec![],
|
||||
})
|
||||
},
|
||||
@@ -71,7 +72,7 @@ fn load_snippets_for_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<Snippet>,
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let snippet_sql = format!(
|
||||
"SELECT id, title, content, content_type, created_at, updated_at \
|
||||
"SELECT id, title, content, content_type, copy_count, created_at, updated_at \
|
||||
FROM snippet WHERE id IN ({placeholders})"
|
||||
);
|
||||
let mut stmt = conn.prepare(&snippet_sql)?;
|
||||
@@ -83,8 +84,9 @@ fn load_snippets_for_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<Snippet>,
|
||||
title: r.get(1)?,
|
||||
content: r.get(2)?,
|
||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||
created_at: r.get(4)?,
|
||||
updated_at: r.get(5)?,
|
||||
copy_count: r.get(4)?,
|
||||
created_at: r.get(5)?,
|
||||
updated_at: r.get(6)?,
|
||||
tags: vec![],
|
||||
})
|
||||
})?
|
||||
@@ -119,7 +121,7 @@ fn db_list_snippets(
|
||||
let ids: Vec<i64> = match tag_filter {
|
||||
None => {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM snippet ORDER BY created_at DESC, id DESC")?;
|
||||
.prepare("SELECT id FROM snippet ORDER BY copy_count DESC, created_at DESC, id DESC")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
@@ -130,7 +132,7 @@ fn db_list_snippets(
|
||||
JOIN snippet_tag st ON st.snippet_id = s.id \
|
||||
JOIN tag t ON t.id = st.tag_id \
|
||||
WHERE t.name = ?1 AND st.source != 'suppressed' \
|
||||
ORDER BY s.created_at DESC, s.id DESC",
|
||||
ORDER BY s.copy_count DESC, s.created_at DESC, s.id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([tag], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub enum ContentType {
|
||||
Code,
|
||||
Cli,
|
||||
Text,
|
||||
Url,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentType {
|
||||
@@ -14,6 +15,7 @@ impl std::fmt::Display for ContentType {
|
||||
ContentType::Code => write!(f, "code"),
|
||||
ContentType::Cli => write!(f, "cli"),
|
||||
ContentType::Text => write!(f, "text"),
|
||||
ContentType::Url => write!(f, "url"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +27,7 @@ impl std::str::FromStr for ContentType {
|
||||
"code" => Ok(ContentType::Code),
|
||||
"cli" => Ok(ContentType::Cli),
|
||||
"text" => Ok(ContentType::Text),
|
||||
"url" => Ok(ContentType::Url),
|
||||
other => Err(format!("unknown content_type: {other}")),
|
||||
}
|
||||
}
|
||||
@@ -36,6 +39,7 @@ pub struct Snippet {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub content_type: ContentType,
|
||||
pub copy_count: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub tags: Vec<Tag>,
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Faro",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"width": 1300,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 520,
|
||||
"resizable": true
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
/* semantic */
|
||||
--success: #c3e88d;
|
||||
--danger: #ef4444;
|
||||
--link: #60a5fa;
|
||||
|
||||
/* syntax */
|
||||
--syntax-keyword: #c792ea;
|
||||
@@ -95,6 +96,7 @@
|
||||
--accent-border: #6d5dfc;
|
||||
--success: #16a34a;
|
||||
--danger: #dc2626;
|
||||
--link: #2563eb;
|
||||
--syntax-keyword: #2563eb;
|
||||
--syntax-type: #0891b2;
|
||||
--syntax-func: #7c3aed;
|
||||
@@ -133,6 +135,7 @@
|
||||
--accent-border: #88c0d0;
|
||||
--success: #a3be8c;
|
||||
--danger: #bf616a;
|
||||
--link: #5e81ac;
|
||||
--syntax-keyword: #81a1c1;
|
||||
--syntax-type: #8fbcbb;
|
||||
--syntax-func: #88c0d0;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Code2, Terminal, FileText, Copy, Check } from 'lucide-svelte';
|
||||
import { Code2, Terminal, FileText, Link, Copy, Check } from 'lucide-svelte';
|
||||
import type { Snippet } from '$lib/types';
|
||||
import { copyToClipboard, recordCopy } from '$lib/api';
|
||||
import { copyToClipboard } from '$lib/api';
|
||||
import { snippets as store } from '$lib/stores/snippets.svelte';
|
||||
import { highlightPreviewHtml } from '$lib/highlight';
|
||||
|
||||
let { snippet, selected = false, onselect }: {
|
||||
@@ -32,7 +33,7 @@
|
||||
await copyToClipboard(snippet.content);
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 1500);
|
||||
recordCopy(snippet.id).catch(() => {});
|
||||
store.recordCopy(snippet.id);
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
</script>
|
||||
@@ -52,11 +53,16 @@
|
||||
<Code2 size={13} strokeWidth={2} />
|
||||
{:else if snippet.content_type === 'cli'}
|
||||
<Terminal size={13} strokeWidth={2} />
|
||||
{:else if snippet.content_type === 'url'}
|
||||
<Link size={13} strokeWidth={2} />
|
||||
{:else}
|
||||
<FileText size={13} strokeWidth={2} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="title">{snippet.title || 'Untitled'}</span>
|
||||
{#if snippet.copy_count > 0}
|
||||
<span class="copy-count">{snippet.copy_count}</span>
|
||||
{/if}
|
||||
<button class="copy-btn" onclick={copyContent}>
|
||||
{#if copied}
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
@@ -113,6 +119,7 @@
|
||||
.type-icon.code { color: var(--accent); }
|
||||
.type-icon.cli { color: var(--success); }
|
||||
.type-icon.text { color: var(--text-muted); }
|
||||
.type-icon.url { color: var(--link); }
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
@@ -121,6 +128,12 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.copy-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { detectSnippet } from '$lib/highlight';
|
||||
import CodeEditor from './CodeEditor.svelte';
|
||||
import { LANGUAGE_OPTIONS } from '$lib/languages';
|
||||
import { Copy, Check } from 'lucide-svelte';
|
||||
import { copyToClipboard } from '$lib/api';
|
||||
import { snippets as store } from '$lib/stores/snippets.svelte';
|
||||
|
||||
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
|
||||
snippet?: Snippet | null;
|
||||
@@ -21,6 +24,16 @@
|
||||
let draftSuppressedTags = $state<string[]>([]);
|
||||
let typeManuallySet = $state(false);
|
||||
let systemTagsManuallySet = $state(false);
|
||||
let detailCopied = $state(false);
|
||||
|
||||
async function handleDetailCopy() {
|
||||
try {
|
||||
await copyToClipboard(draftContent);
|
||||
detailCopied = true;
|
||||
setTimeout(() => { detailCopied = false; }, 1500);
|
||||
if (snippet) store.recordCopy(snippet.id);
|
||||
} catch { /* clipboard unavailable */ }
|
||||
}
|
||||
|
||||
// Re-initialize draft whenever mode or snippet changes.
|
||||
$effect(() => {
|
||||
@@ -145,7 +158,7 @@
|
||||
<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)}
|
||||
{#each (['code', 'cli', 'text', 'url'] as ContentType[]) as t (t)}
|
||||
<button
|
||||
class="type-btn"
|
||||
class:active={draftType === t}
|
||||
@@ -170,6 +183,20 @@
|
||||
</div>
|
||||
|
||||
<div class="field grow">
|
||||
<div class="content-label-row">
|
||||
<label>Content</label>
|
||||
{#if mode === 'editing' && snippet}
|
||||
<button class="content-copy-btn" onclick={handleDetailCopy}>
|
||||
{#if detailCopied}
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
<span>Copied</span>
|
||||
{:else}
|
||||
<Copy size={12} strokeWidth={2} />
|
||||
<span>Copy</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<CodeEditor
|
||||
bind:value={draftContent}
|
||||
contentType={draftType}
|
||||
@@ -268,6 +295,30 @@
|
||||
.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; }
|
||||
.content-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.content-copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
padding: 0.15rem 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.content-copy-btn:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.system-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import SnippetCard from '$lib/components/SnippetCard.svelte';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import type { Snippet, TimePeriod } from '$lib/types';
|
||||
import type { Snippet, TimePeriod, ContentType } from '$lib/types';
|
||||
|
||||
let { snippets, selectedId, loading = false, searchQuery, tagFilter = null, timePeriodFilter = null, onSelect, onSearch, searchInputEl = $bindable() }: {
|
||||
let { snippets, selectedId, loading = false, searchQuery, tagFilter = null, contentTypeFilter = null, timePeriodFilter = null, onSelect, onSearch, searchInputEl = $bindable() }: {
|
||||
snippets: Snippet[];
|
||||
selectedId: number | null;
|
||||
loading?: boolean;
|
||||
searchQuery: string;
|
||||
tagFilter?: string | null;
|
||||
contentTypeFilter?: ContentType | null;
|
||||
timePeriodFilter?: TimePeriod | null;
|
||||
onSelect: (id: number) => void;
|
||||
onSearch: (q: string) => void;
|
||||
@@ -23,14 +24,25 @@
|
||||
'older': 'older than two weeks',
|
||||
};
|
||||
|
||||
const CT_LABELS: Record<ContentType, string> = {
|
||||
code: 'code',
|
||||
cli: 'CLI',
|
||||
text: 'text',
|
||||
url: 'URL',
|
||||
};
|
||||
|
||||
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.'
|
||||
: tagFilter && contentTypeFilter
|
||||
? `No ${CT_LABELS[contentTypeFilter]} snippets tagged "${tagFilter}".`
|
||||
: tagFilter
|
||||
? `No snippets tagged "${tagFilter}".`
|
||||
: contentTypeFilter
|
||||
? `No ${CT_LABELS[contentTypeFilter]} snippets.`
|
||||
: 'No snippets yet — create one with the button on the left.'
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Settings, Plus, Clock, Tag as TagIcon, Search } from 'lucide-svelte';
|
||||
import type { Tag, TimePeriod } from '$lib/types';
|
||||
import { Settings, Plus, Clock, Tag as TagIcon, Search, Code2, Terminal, FileText, Link, Layers } from 'lucide-svelte';
|
||||
import type { Tag, TimePeriod, ContentType } from '$lib/types';
|
||||
|
||||
const TIMELINE_ITEMS: { label: string; value: TimePeriod }[] = [
|
||||
{ label: 'Today', value: 'today' },
|
||||
@@ -10,10 +10,19 @@
|
||||
{ label: 'Older', value: 'older' },
|
||||
];
|
||||
|
||||
const CONTENT_TYPES: { label: string; value: ContentType; Icon: typeof Code2 }[] = [
|
||||
{ label: 'Code', value: 'code', Icon: Code2 },
|
||||
{ label: 'CLI', value: 'cli', Icon: Terminal },
|
||||
{ label: 'Text', value: 'text', Icon: FileText },
|
||||
{ label: 'Url', value: 'url', Icon: Link },
|
||||
];
|
||||
|
||||
let {
|
||||
tags,
|
||||
activeFilter = null,
|
||||
contentTypeFilter = null,
|
||||
onFilterChange,
|
||||
onContentTypeChange = () => {},
|
||||
onNewSnippet,
|
||||
onOpenSettings = () => {},
|
||||
timePeriodFilter = null,
|
||||
@@ -21,7 +30,9 @@
|
||||
}: {
|
||||
tags: Tag[];
|
||||
activeFilter?: string | null;
|
||||
contentTypeFilter?: ContentType | null;
|
||||
onFilterChange: (tag: string | null) => void;
|
||||
onContentTypeChange?: (ct: ContentType | null) => void;
|
||||
onNewSnippet: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
timePeriodFilter?: TimePeriod | null;
|
||||
@@ -34,14 +45,13 @@
|
||||
? tags.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||
: tags
|
||||
);
|
||||
|
||||
function toggleContentType(ct: ContentType) {
|
||||
onContentTypeChange(contentTypeFilter === ct ? null : ct);
|
||||
}
|
||||
</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">
|
||||
@@ -57,6 +67,26 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="divider-strong"></div>
|
||||
|
||||
<!-- Content Type section -->
|
||||
<div class="section">
|
||||
<div class="section-label"><Layers size={10} strokeWidth={2.5} />Content Type</div>
|
||||
<div class="ct-row">
|
||||
{#each CONTENT_TYPES as ct (ct.value)}
|
||||
<button
|
||||
class="ct-chip"
|
||||
class:active={contentTypeFilter === ct.value}
|
||||
onclick={() => toggleContentType(ct.value)}
|
||||
title={ct.label}
|
||||
>
|
||||
<ct.Icon size={12} strokeWidth={2} />
|
||||
<span>{ct.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tags section -->
|
||||
@@ -75,8 +105,8 @@
|
||||
{#if !tagSearch.trim()}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={activeFilter === null && timePeriodFilter === null}
|
||||
onclick={() => { onFilterChange(null); onTimePeriodChange(null); }}
|
||||
class:active={activeFilter === null && timePeriodFilter === null && contentTypeFilter === null}
|
||||
onclick={() => { onFilterChange(null); onTimePeriodChange(null); onContentTypeChange(null); }}
|
||||
>
|
||||
All snippets
|
||||
</button>
|
||||
@@ -117,20 +147,6 @@
|
||||
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;
|
||||
@@ -162,6 +178,46 @@
|
||||
background: var(--border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.divider-strong {
|
||||
height: 1px;
|
||||
background: var(--border-strong, var(--border));
|
||||
margin: 0.5rem 0;
|
||||
opacity: 0.6;
|
||||
box-shadow: 0 1px 0 var(--border);
|
||||
}
|
||||
/* Content type chip row */
|
||||
.ct-row {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 1rem 0.35rem;
|
||||
}
|
||||
.ct-chip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.35rem 0.2rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
.ct-chip:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.ct-chip.active {
|
||||
background: var(--accent-tint);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.tag-search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -2,6 +2,19 @@ import hljs from 'highlight.js/lib/common';
|
||||
import type { ContentType } from './types';
|
||||
import { SUBSET } from './languages';
|
||||
|
||||
// ── URL recognition ──────────────────────────────────────────────────────────
|
||||
|
||||
const URL_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']);
|
||||
|
||||
function looksLikeUrl(content: string): boolean {
|
||||
if (content.includes('\n')) return false;
|
||||
try {
|
||||
return URL_PROTOCOLS.has(new URL(content).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI recognition ──────────────────────────────────────────────────────────
|
||||
|
||||
const SHELL_COMMANDS = new Set([
|
||||
@@ -65,6 +78,10 @@ export function detectSnippet(
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return { content_type: 'text', language: null };
|
||||
|
||||
if (looksLikeUrl(trimmed)) {
|
||||
return { content_type: 'url', language: null };
|
||||
}
|
||||
|
||||
if (looksLikeCli(trimmed)) {
|
||||
return { content_type: 'cli', language: null };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
|
||||
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod } from '$lib/api';
|
||||
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod, ContentType } from '$lib/types';
|
||||
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod, recordCopy as apiRecordCopy } from '$lib/api';
|
||||
|
||||
class SnippetStore {
|
||||
snippets = $state<Snippet[]>([]);
|
||||
selectedId = $state<number | null>(null);
|
||||
tags = $state<Tag[]>([]);
|
||||
tagFilter = $state<string | null>(null);
|
||||
contentTypeFilter = $state<ContentType | null>(null);
|
||||
searchQuery = $state<string>('');
|
||||
timePeriodFilter = $state<TimePeriod | null>(null);
|
||||
loading = $state(false);
|
||||
@@ -15,7 +16,7 @@ class SnippetStore {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const [list, tagList] = await Promise.all([
|
||||
const [rawList, tagList] = await Promise.all([
|
||||
this.searchQuery
|
||||
? searchSnippets(this.searchQuery)
|
||||
: this.timePeriodFilter
|
||||
@@ -23,7 +24,9 @@ class SnippetStore {
|
||||
: listSnippets(this.tagFilter ?? undefined),
|
||||
listTags(),
|
||||
]);
|
||||
this.snippets = list;
|
||||
this.snippets = this.contentTypeFilter
|
||||
? rawList.filter(s => s.content_type === this.contentTypeFilter)
|
||||
: rawList;
|
||||
this.tags = tagList;
|
||||
} catch (e) {
|
||||
this.error = String(e);
|
||||
@@ -68,6 +71,28 @@ class SnippetStore {
|
||||
}
|
||||
}
|
||||
|
||||
recordCopy(id: number): void {
|
||||
const idx = this.snippets.findIndex(s => s.id === id);
|
||||
if (idx >= 0) this.snippets[idx].copy_count += 1;
|
||||
apiRecordCopy(id).catch(() => {});
|
||||
}
|
||||
|
||||
get visibleTags(): Tag[] {
|
||||
if (!this.contentTypeFilter) return this.tags;
|
||||
const map = new Map<number, Tag & { count: number }>();
|
||||
for (const snippet of this.snippets) {
|
||||
for (const tag of snippet.tags) {
|
||||
const entry = map.get(tag.id);
|
||||
if (entry) {
|
||||
entry.count++;
|
||||
} else {
|
||||
map.set(tag.id, { ...tag, count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
select(id: number | null): void {
|
||||
this.selectedId = id;
|
||||
}
|
||||
@@ -76,12 +101,22 @@ class SnippetStore {
|
||||
this.tagFilter = tag;
|
||||
this.searchQuery = '';
|
||||
this.timePeriodFilter = null;
|
||||
// contentTypeFilter composes with tagFilter — don't clear it
|
||||
void this.load();
|
||||
}
|
||||
|
||||
setContentTypeFilter(ct: ContentType | null): void {
|
||||
this.contentTypeFilter = ct;
|
||||
this.timePeriodFilter = null;
|
||||
this.searchQuery = '';
|
||||
// tagFilter composes with contentTypeFilter — don't clear it
|
||||
void this.load();
|
||||
}
|
||||
|
||||
setSearch(query: string): void {
|
||||
this.searchQuery = query;
|
||||
this.tagFilter = null;
|
||||
this.contentTypeFilter = null;
|
||||
this.timePeriodFilter = null;
|
||||
void this.load();
|
||||
}
|
||||
@@ -89,6 +124,7 @@ class SnippetStore {
|
||||
setTimePeriodFilter(period: TimePeriod | null): void {
|
||||
this.timePeriodFilter = period;
|
||||
this.tagFilter = null;
|
||||
this.contentTypeFilter = null;
|
||||
this.searchQuery = '';
|
||||
void this.load();
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
export type ContentType = 'code' | 'cli' | 'text';
|
||||
export type ContentType = 'code' | 'cli' | 'text' | 'url';
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
@@ -15,6 +15,7 @@ export interface Snippet {
|
||||
title: string;
|
||||
content: string;
|
||||
content_type: ContentType;
|
||||
copy_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: Tag[];
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@
|
||||
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';
|
||||
import type { CreateSnippetInput, UpdateSnippetInput, TimePeriod, ContentType } from '$lib/types';
|
||||
|
||||
let mode = $state<'idle' | 'creating' | 'editing'>('idle');
|
||||
let formDirty = $state(false);
|
||||
@@ -118,8 +118,9 @@
|
||||
|
||||
<div class="app-layout">
|
||||
<LeftPanel
|
||||
tags={store.tags}
|
||||
tags={store.visibleTags}
|
||||
activeFilter={store.tagFilter}
|
||||
contentTypeFilter={store.contentTypeFilter}
|
||||
timePeriodFilter={store.timePeriodFilter}
|
||||
onFilterChange={(tag) => {
|
||||
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
||||
@@ -127,6 +128,12 @@
|
||||
store.select(null);
|
||||
mode = 'idle';
|
||||
}}
|
||||
onContentTypeChange={(ct: ContentType | null) => {
|
||||
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
||||
store.setContentTypeFilter(ct);
|
||||
store.select(null);
|
||||
mode = 'idle';
|
||||
}}
|
||||
onTimePeriodChange={(period: TimePeriod | null) => {
|
||||
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
||||
store.setTimePeriodFilter(period);
|
||||
@@ -142,6 +149,7 @@
|
||||
loading={store.loading}
|
||||
searchQuery={store.searchQuery}
|
||||
tagFilter={store.tagFilter}
|
||||
contentTypeFilter={store.contentTypeFilter}
|
||||
timePeriodFilter={store.timePeriodFilter}
|
||||
onSelect={handleSelectSnippet}
|
||||
onSearch={(q) => {
|
||||
@@ -170,7 +178,7 @@
|
||||
<style>
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 360px;
|
||||
grid-template-columns: 270px 1fr 360px;
|
||||
grid-template-rows: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<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)}
|
||||
{#each (['code', 'cli', 'text', 'url'] as ContentType[]) as t (t)}
|
||||
<button
|
||||
class="type-btn"
|
||||
class:active={contentType === t}
|
||||
|
||||
Reference in New Issue
Block a user