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.
|
FTS5-powered search across all snippet titles and content. Results update as you type.
|
||||||
|
|
||||||
### Auto Content Detection
|
### 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
|
### 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
|
### Tag System
|
||||||
Two tag sources coexist on each snippet:
|
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:
|
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> {
|
fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
||||||
let mut snippet = conn.query_row(
|
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",
|
FROM snippet WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -35,8 +35,9 @@ fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
|||||||
title: r.get(1)?,
|
title: r.get(1)?,
|
||||||
content: r.get(2)?,
|
content: r.get(2)?,
|
||||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||||
created_at: r.get(4)?,
|
copy_count: r.get(4)?,
|
||||||
updated_at: r.get(5)?,
|
created_at: r.get(5)?,
|
||||||
|
updated_at: r.get(6)?,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -71,7 +72,7 @@ fn load_snippets_for_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<Snippet>,
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let snippet_sql = format!(
|
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})"
|
FROM snippet WHERE id IN ({placeholders})"
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&snippet_sql)?;
|
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)?,
|
title: r.get(1)?,
|
||||||
content: r.get(2)?,
|
content: r.get(2)?,
|
||||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||||
created_at: r.get(4)?,
|
copy_count: r.get(4)?,
|
||||||
updated_at: r.get(5)?,
|
created_at: r.get(5)?,
|
||||||
|
updated_at: r.get(6)?,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
@@ -119,7 +121,7 @@ fn db_list_snippets(
|
|||||||
let ids: Vec<i64> = match tag_filter {
|
let ids: Vec<i64> = match tag_filter {
|
||||||
None => {
|
None => {
|
||||||
let mut stmt = conn
|
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))?
|
let rows = stmt.query_map([], |r| r.get(0))?
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
rows
|
rows
|
||||||
@@ -130,7 +132,7 @@ fn db_list_snippets(
|
|||||||
JOIN snippet_tag st ON st.snippet_id = s.id \
|
JOIN snippet_tag st ON st.snippet_id = s.id \
|
||||||
JOIN tag t ON t.id = st.tag_id \
|
JOIN tag t ON t.id = st.tag_id \
|
||||||
WHERE t.name = ?1 AND st.source != 'suppressed' \
|
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))?
|
let rows = stmt.query_map([tag], |r| r.get(0))?
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub enum ContentType {
|
|||||||
Code,
|
Code,
|
||||||
Cli,
|
Cli,
|
||||||
Text,
|
Text,
|
||||||
|
Url,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ContentType {
|
impl std::fmt::Display for ContentType {
|
||||||
@@ -14,6 +15,7 @@ impl std::fmt::Display for ContentType {
|
|||||||
ContentType::Code => write!(f, "code"),
|
ContentType::Code => write!(f, "code"),
|
||||||
ContentType::Cli => write!(f, "cli"),
|
ContentType::Cli => write!(f, "cli"),
|
||||||
ContentType::Text => write!(f, "text"),
|
ContentType::Text => write!(f, "text"),
|
||||||
|
ContentType::Url => write!(f, "url"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,7 @@ impl std::str::FromStr for ContentType {
|
|||||||
"code" => Ok(ContentType::Code),
|
"code" => Ok(ContentType::Code),
|
||||||
"cli" => Ok(ContentType::Cli),
|
"cli" => Ok(ContentType::Cli),
|
||||||
"text" => Ok(ContentType::Text),
|
"text" => Ok(ContentType::Text),
|
||||||
|
"url" => Ok(ContentType::Url),
|
||||||
other => Err(format!("unknown content_type: {other}")),
|
other => Err(format!("unknown content_type: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,6 +39,7 @@ pub struct Snippet {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub content_type: ContentType,
|
pub content_type: ContentType,
|
||||||
|
pub copy_count: i64,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub tags: Vec<Tag>,
|
pub tags: Vec<Tag>,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Faro",
|
"title": "Faro",
|
||||||
"width": 1100,
|
"width": 1300,
|
||||||
"height": 720,
|
"height": 900,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 520,
|
"minHeight": 520,
|
||||||
"resizable": true
|
"resizable": true
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
/* semantic */
|
/* semantic */
|
||||||
--success: #c3e88d;
|
--success: #c3e88d;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
|
--link: #60a5fa;
|
||||||
|
|
||||||
/* syntax */
|
/* syntax */
|
||||||
--syntax-keyword: #c792ea;
|
--syntax-keyword: #c792ea;
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
--accent-border: #6d5dfc;
|
--accent-border: #6d5dfc;
|
||||||
--success: #16a34a;
|
--success: #16a34a;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
|
--link: #2563eb;
|
||||||
--syntax-keyword: #2563eb;
|
--syntax-keyword: #2563eb;
|
||||||
--syntax-type: #0891b2;
|
--syntax-type: #0891b2;
|
||||||
--syntax-func: #7c3aed;
|
--syntax-func: #7c3aed;
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
--accent-border: #88c0d0;
|
--accent-border: #88c0d0;
|
||||||
--success: #a3be8c;
|
--success: #a3be8c;
|
||||||
--danger: #bf616a;
|
--danger: #bf616a;
|
||||||
|
--link: #5e81ac;
|
||||||
--syntax-keyword: #81a1c1;
|
--syntax-keyword: #81a1c1;
|
||||||
--syntax-type: #8fbcbb;
|
--syntax-type: #8fbcbb;
|
||||||
--syntax-func: #88c0d0;
|
--syntax-func: #88c0d0;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<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 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';
|
import { highlightPreviewHtml } from '$lib/highlight';
|
||||||
|
|
||||||
let { snippet, selected = false, onselect }: {
|
let { snippet, selected = false, onselect }: {
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
await copyToClipboard(snippet.content);
|
await copyToClipboard(snippet.content);
|
||||||
copied = true;
|
copied = true;
|
||||||
setTimeout(() => { copied = false; }, 1500);
|
setTimeout(() => { copied = false; }, 1500);
|
||||||
recordCopy(snippet.id).catch(() => {});
|
store.recordCopy(snippet.id);
|
||||||
} catch { /* clipboard unavailable */ }
|
} catch { /* clipboard unavailable */ }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -52,11 +53,16 @@
|
|||||||
<Code2 size={13} strokeWidth={2} />
|
<Code2 size={13} strokeWidth={2} />
|
||||||
{:else if snippet.content_type === 'cli'}
|
{:else if snippet.content_type === 'cli'}
|
||||||
<Terminal size={13} strokeWidth={2} />
|
<Terminal size={13} strokeWidth={2} />
|
||||||
|
{:else if snippet.content_type === 'url'}
|
||||||
|
<Link size={13} strokeWidth={2} />
|
||||||
{:else}
|
{:else}
|
||||||
<FileText size={13} strokeWidth={2} />
|
<FileText size={13} strokeWidth={2} />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="title">{snippet.title || 'Untitled'}</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}>
|
<button class="copy-btn" onclick={copyContent}>
|
||||||
{#if copied}
|
{#if copied}
|
||||||
<Check size={12} strokeWidth={2.5} />
|
<Check size={12} strokeWidth={2.5} />
|
||||||
@@ -113,6 +119,7 @@
|
|||||||
.type-icon.code { color: var(--accent); }
|
.type-icon.code { color: var(--accent); }
|
||||||
.type-icon.cli { color: var(--success); }
|
.type-icon.cli { color: var(--success); }
|
||||||
.type-icon.text { color: var(--text-muted); }
|
.type-icon.text { color: var(--text-muted); }
|
||||||
|
.type-icon.url { color: var(--link); }
|
||||||
.title {
|
.title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -121,6 +128,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.copy-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { detectSnippet } from '$lib/highlight';
|
import { detectSnippet } from '$lib/highlight';
|
||||||
import CodeEditor from './CodeEditor.svelte';
|
import CodeEditor from './CodeEditor.svelte';
|
||||||
import { LANGUAGE_OPTIONS } from '$lib/languages';
|
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) }: {
|
let { snippet = null, mode, onSave, onCancel, onDelete, dirty = $bindable(false) }: {
|
||||||
snippet?: Snippet | null;
|
snippet?: Snippet | null;
|
||||||
@@ -21,6 +24,16 @@
|
|||||||
let draftSuppressedTags = $state<string[]>([]);
|
let draftSuppressedTags = $state<string[]>([]);
|
||||||
let typeManuallySet = $state(false);
|
let typeManuallySet = $state(false);
|
||||||
let systemTagsManuallySet = $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.
|
// Re-initialize draft whenever mode or snippet changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -145,7 +158,7 @@
|
|||||||
<label for="type-buttons">Type</label>
|
<label for="type-buttons">Type</label>
|
||||||
<div class="type-row">
|
<div class="type-row">
|
||||||
<div class="type-buttons" id="type-buttons" role="group">
|
<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
|
<button
|
||||||
class="type-btn"
|
class="type-btn"
|
||||||
class:active={draftType === t}
|
class:active={draftType === t}
|
||||||
@@ -170,6 +183,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field grow">
|
<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
|
<CodeEditor
|
||||||
bind:value={draftContent}
|
bind:value={draftContent}
|
||||||
contentType={draftType}
|
contentType={draftType}
|
||||||
@@ -268,6 +295,30 @@
|
|||||||
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
|
.title-input:hover:not(:focus) { border-bottom-color: var(--border); }
|
||||||
.title-input:focus { border-bottom-color: var(--accent-border); }
|
.title-input:focus { border-bottom-color: var(--accent-border); }
|
||||||
.title-input::placeholder { color: var(--text-faint); font-weight: 400; }
|
.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 {
|
.system-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SnippetCard from '$lib/components/SnippetCard.svelte';
|
import SnippetCard from '$lib/components/SnippetCard.svelte';
|
||||||
import SearchBar from '$lib/components/SearchBar.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[];
|
snippets: Snippet[];
|
||||||
selectedId: number | null;
|
selectedId: number | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
tagFilter?: string | null;
|
tagFilter?: string | null;
|
||||||
|
contentTypeFilter?: ContentType | null;
|
||||||
timePeriodFilter?: TimePeriod | null;
|
timePeriodFilter?: TimePeriod | null;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
onSearch: (q: string) => void;
|
onSearch: (q: string) => void;
|
||||||
@@ -23,14 +24,25 @@
|
|||||||
'older': 'older than two weeks',
|
'older': 'older than two weeks',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CT_LABELS: Record<ContentType, string> = {
|
||||||
|
code: 'code',
|
||||||
|
cli: 'CLI',
|
||||||
|
text: 'text',
|
||||||
|
url: 'URL',
|
||||||
|
};
|
||||||
|
|
||||||
const emptyMessage = $derived(
|
const emptyMessage = $derived(
|
||||||
searchQuery
|
searchQuery
|
||||||
? 'No snippets match your search.'
|
? 'No snippets match your search.'
|
||||||
: timePeriodFilter
|
: timePeriodFilter
|
||||||
? `No snippets from ${PERIOD_LABELS[timePeriodFilter]}.`
|
? `No snippets from ${PERIOD_LABELS[timePeriodFilter]}.`
|
||||||
: tagFilter
|
: tagFilter && contentTypeFilter
|
||||||
? `No snippets tagged "${tagFilter}".`
|
? `No ${CT_LABELS[contentTypeFilter]} snippets tagged "${tagFilter}".`
|
||||||
: 'No snippets yet — create one with the button on the left.'
|
: tagFilter
|
||||||
|
? `No snippets tagged "${tagFilter}".`
|
||||||
|
: contentTypeFilter
|
||||||
|
? `No ${CT_LABELS[contentTypeFilter]} snippets.`
|
||||||
|
: 'No snippets yet — create one with the button on the left.'
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings, Plus, Clock, Tag as TagIcon, Search } from 'lucide-svelte';
|
import { Settings, Plus, Clock, Tag as TagIcon, Search, Code2, Terminal, FileText, Link, Layers } from 'lucide-svelte';
|
||||||
import type { Tag, TimePeriod } from '$lib/types';
|
import type { Tag, TimePeriod, ContentType } from '$lib/types';
|
||||||
|
|
||||||
const TIMELINE_ITEMS: { label: string; value: TimePeriod }[] = [
|
const TIMELINE_ITEMS: { label: string; value: TimePeriod }[] = [
|
||||||
{ label: 'Today', value: 'today' },
|
{ label: 'Today', value: 'today' },
|
||||||
@@ -10,10 +10,19 @@
|
|||||||
{ label: 'Older', value: 'older' },
|
{ 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 {
|
let {
|
||||||
tags,
|
tags,
|
||||||
activeFilter = null,
|
activeFilter = null,
|
||||||
|
contentTypeFilter = null,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onContentTypeChange = () => {},
|
||||||
onNewSnippet,
|
onNewSnippet,
|
||||||
onOpenSettings = () => {},
|
onOpenSettings = () => {},
|
||||||
timePeriodFilter = null,
|
timePeriodFilter = null,
|
||||||
@@ -21,7 +30,9 @@
|
|||||||
}: {
|
}: {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
activeFilter?: string | null;
|
activeFilter?: string | null;
|
||||||
|
contentTypeFilter?: ContentType | null;
|
||||||
onFilterChange: (tag: string | null) => void;
|
onFilterChange: (tag: string | null) => void;
|
||||||
|
onContentTypeChange?: (ct: ContentType | null) => void;
|
||||||
onNewSnippet: () => void;
|
onNewSnippet: () => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
timePeriodFilter?: TimePeriod | null;
|
timePeriodFilter?: TimePeriod | null;
|
||||||
@@ -34,14 +45,13 @@
|
|||||||
? tags.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
? tags.filter(t => t.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||||
: tags
|
: tags
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function toggleContentType(ct: ContentType) {
|
||||||
|
onContentTypeChange(contentTypeFilter === ct ? null : ct);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="left-panel">
|
<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">
|
<div class="panel-body">
|
||||||
<!-- Timeline section -->
|
<!-- Timeline section -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@@ -57,6 +67,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<!-- Tags section -->
|
<!-- Tags section -->
|
||||||
@@ -75,8 +105,8 @@
|
|||||||
{#if !tagSearch.trim()}
|
{#if !tagSearch.trim()}
|
||||||
<button
|
<button
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={activeFilter === null && timePeriodFilter === null}
|
class:active={activeFilter === null && timePeriodFilter === null && contentTypeFilter === null}
|
||||||
onclick={() => { onFilterChange(null); onTimePeriodChange(null); }}
|
onclick={() => { onFilterChange(null); onTimePeriodChange(null); onContentTypeChange(null); }}
|
||||||
>
|
>
|
||||||
All snippets
|
All snippets
|
||||||
</button>
|
</button>
|
||||||
@@ -117,20 +147,6 @@
|
|||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--bg-sidebar);
|
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 {
|
.panel-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -162,6 +178,46 @@
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
margin: 0.25rem 0;
|
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 {
|
.tag-search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import hljs from 'highlight.js/lib/common';
|
|||||||
import type { ContentType } from './types';
|
import type { ContentType } from './types';
|
||||||
import { SUBSET } from './languages';
|
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 ──────────────────────────────────────────────────────────
|
// ── CLI recognition ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SHELL_COMMANDS = new Set([
|
const SHELL_COMMANDS = new Set([
|
||||||
@@ -65,6 +78,10 @@ export function detectSnippet(
|
|||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) return { content_type: 'text', language: null };
|
if (!trimmed) return { content_type: 'text', language: null };
|
||||||
|
|
||||||
|
if (looksLikeUrl(trimmed)) {
|
||||||
|
return { content_type: 'url', language: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (looksLikeCli(trimmed)) {
|
if (looksLikeCli(trimmed)) {
|
||||||
return { content_type: 'cli', language: null };
|
return { content_type: 'cli', language: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod } from '$lib/types';
|
import type { Snippet, Tag, CreateSnippetInput, UpdateSnippetInput, TimePeriod, ContentType } from '$lib/types';
|
||||||
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod } from '$lib/api';
|
import { listSnippets, createSnippet, updateSnippet, deleteSnippet, listTags, searchSnippets, listSnippetsByPeriod, recordCopy as apiRecordCopy } from '$lib/api';
|
||||||
|
|
||||||
class SnippetStore {
|
class SnippetStore {
|
||||||
snippets = $state<Snippet[]>([]);
|
snippets = $state<Snippet[]>([]);
|
||||||
selectedId = $state<number | null>(null);
|
selectedId = $state<number | null>(null);
|
||||||
tags = $state<Tag[]>([]);
|
tags = $state<Tag[]>([]);
|
||||||
tagFilter = $state<string | null>(null);
|
tagFilter = $state<string | null>(null);
|
||||||
|
contentTypeFilter = $state<ContentType | null>(null);
|
||||||
searchQuery = $state<string>('');
|
searchQuery = $state<string>('');
|
||||||
timePeriodFilter = $state<TimePeriod | null>(null);
|
timePeriodFilter = $state<TimePeriod | null>(null);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
@@ -15,7 +16,7 @@ class SnippetStore {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
const [list, tagList] = await Promise.all([
|
const [rawList, tagList] = await Promise.all([
|
||||||
this.searchQuery
|
this.searchQuery
|
||||||
? searchSnippets(this.searchQuery)
|
? searchSnippets(this.searchQuery)
|
||||||
: this.timePeriodFilter
|
: this.timePeriodFilter
|
||||||
@@ -23,7 +24,9 @@ class SnippetStore {
|
|||||||
: listSnippets(this.tagFilter ?? undefined),
|
: listSnippets(this.tagFilter ?? undefined),
|
||||||
listTags(),
|
listTags(),
|
||||||
]);
|
]);
|
||||||
this.snippets = list;
|
this.snippets = this.contentTypeFilter
|
||||||
|
? rawList.filter(s => s.content_type === this.contentTypeFilter)
|
||||||
|
: rawList;
|
||||||
this.tags = tagList;
|
this.tags = tagList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = String(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 {
|
select(id: number | null): void {
|
||||||
this.selectedId = id;
|
this.selectedId = id;
|
||||||
}
|
}
|
||||||
@@ -76,12 +101,22 @@ class SnippetStore {
|
|||||||
this.tagFilter = tag;
|
this.tagFilter = tag;
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.timePeriodFilter = null;
|
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();
|
void this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearch(query: string): void {
|
setSearch(query: string): void {
|
||||||
this.searchQuery = query;
|
this.searchQuery = query;
|
||||||
this.tagFilter = null;
|
this.tagFilter = null;
|
||||||
|
this.contentTypeFilter = null;
|
||||||
this.timePeriodFilter = null;
|
this.timePeriodFilter = null;
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
@@ -89,6 +124,7 @@ class SnippetStore {
|
|||||||
setTimePeriodFilter(period: TimePeriod | null): void {
|
setTimePeriodFilter(period: TimePeriod | null): void {
|
||||||
this.timePeriodFilter = period;
|
this.timePeriodFilter = period;
|
||||||
this.tagFilter = null;
|
this.tagFilter = null;
|
||||||
|
this.contentTypeFilter = null;
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
void this.load();
|
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 {
|
export interface Tag {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,6 +15,7 @@ export interface Snippet {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
content_type: ContentType;
|
content_type: ContentType;
|
||||||
|
copy_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
|||||||
+11
-3
@@ -7,7 +7,7 @@
|
|||||||
import CenterPanel from '$lib/components/layout/CenterPanel.svelte';
|
import CenterPanel from '$lib/components/layout/CenterPanel.svelte';
|
||||||
import RightPanel from '$lib/components/layout/RightPanel.svelte';
|
import RightPanel from '$lib/components/layout/RightPanel.svelte';
|
||||||
import SettingsModal from '$lib/components/SettingsModal.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 mode = $state<'idle' | 'creating' | 'editing'>('idle');
|
||||||
let formDirty = $state(false);
|
let formDirty = $state(false);
|
||||||
@@ -118,8 +118,9 @@
|
|||||||
|
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
<LeftPanel
|
<LeftPanel
|
||||||
tags={store.tags}
|
tags={store.visibleTags}
|
||||||
activeFilter={store.tagFilter}
|
activeFilter={store.tagFilter}
|
||||||
|
contentTypeFilter={store.contentTypeFilter}
|
||||||
timePeriodFilter={store.timePeriodFilter}
|
timePeriodFilter={store.timePeriodFilter}
|
||||||
onFilterChange={(tag) => {
|
onFilterChange={(tag) => {
|
||||||
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
||||||
@@ -127,6 +128,12 @@
|
|||||||
store.select(null);
|
store.select(null);
|
||||||
mode = 'idle';
|
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) => {
|
onTimePeriodChange={(period: TimePeriod | null) => {
|
||||||
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
if (formDirty && !confirm('You have unsaved changes. Discard them?')) return;
|
||||||
store.setTimePeriodFilter(period);
|
store.setTimePeriodFilter(period);
|
||||||
@@ -142,6 +149,7 @@
|
|||||||
loading={store.loading}
|
loading={store.loading}
|
||||||
searchQuery={store.searchQuery}
|
searchQuery={store.searchQuery}
|
||||||
tagFilter={store.tagFilter}
|
tagFilter={store.tagFilter}
|
||||||
|
contentTypeFilter={store.contentTypeFilter}
|
||||||
timePeriodFilter={store.timePeriodFilter}
|
timePeriodFilter={store.timePeriodFilter}
|
||||||
onSelect={handleSelectSnippet}
|
onSelect={handleSelectSnippet}
|
||||||
onSearch={(q) => {
|
onSearch={(q) => {
|
||||||
@@ -170,7 +178,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr 360px;
|
grid-template-columns: 270px 1fr 360px;
|
||||||
grid-template-rows: 100vh;
|
grid-template-rows: 100vh;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="type-buttons">Type</label>
|
<label for="type-buttons">Type</label>
|
||||||
<div class="type-buttons" id="type-buttons" role="group">
|
<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
|
<button
|
||||||
class="type-btn"
|
class="type-btn"
|
||||||
class:active={contentType === t}
|
class:active={contentType === t}
|
||||||
|
|||||||
Reference in New Issue
Block a user