From ce07d7965262b0cfa5e5172a0d893f05bb886411 Mon Sep 17 00:00:00 2001 From: Thomas Richter Date: Sun, 1 Feb 2026 21:59:04 +0100 Subject: [PATCH] feat: parse hashtags from content instead of dedicated tag input Tags are now automatically extracted from #hashtags in content text. Removed TagInput from entry editing, keeping it only in FilterBar. Co-Authored-By: Claude Opus 4.5 --- src/lib/components/EntryCard.svelte | 31 +++-------------------------- src/lib/components/EntryList.svelte | 9 ++++----- src/lib/utils/parseHashtags.ts | 31 +++++++++++++++++++++++++++++ src/routes/+page.server.ts | 15 ++++++++++++++ src/routes/+page.svelte | 2 +- 5 files changed, 54 insertions(+), 34 deletions(-) create mode 100644 src/lib/utils/parseHashtags.ts diff --git a/src/lib/components/EntryCard.svelte b/src/lib/components/EntryCard.svelte index d001f6b..5b48cdb 100644 --- a/src/lib/components/EntryCard.svelte +++ b/src/lib/components/EntryCard.svelte @@ -5,8 +5,8 @@ import { slide } from 'svelte/transition'; import ImageGallery from './ImageGallery.svelte'; import ImageUpload from './ImageUpload.svelte'; - import TagInput from './TagInput.svelte'; import { highlightText } from '$lib/utils/highlightText'; + import { highlightHashtags } from '$lib/utils/parseHashtags'; interface EntryWithData extends Entry { images: Image[]; @@ -15,11 +15,10 @@ interface Props { entry: EntryWithData; - availableTags: Tag[]; searchQuery?: string; } - let { entry, availableTags, searchQuery = '' }: Props = $props(); + let { entry, searchQuery = '' }: Props = $props(); // Expand/collapse state let expanded = $state(false); @@ -33,7 +32,6 @@ let editContent = $state(entry.content); let editType = $state(entry.type); let editDueDate = $state(entry.dueDate || ''); - let editTags = $state(entry.tags || []); // Sync edit state when entry changes (after invalidateAll) $effect(() => { @@ -42,7 +40,6 @@ editContent = entry.content; editType = entry.type; editDueDate = entry.dueDate || ''; - editTags = entry.tags || []; } }); @@ -192,22 +189,6 @@ await invalidateAll(); } - async function handleTagsChange(newTags: Tag[]) { - editTags = newTags; - - // Save immediately (no debounce for tags) - const formData = new FormData(); - formData.append('id', entry.id); - formData.append('tags', JSON.stringify(newTags.map((t) => t.name))); - - await fetch('?/updateTags', { - method: 'POST', - body: formData - }); - - await invalidateAll(); - } - async function handleCameraInput(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; @@ -317,7 +298,7 @@ {#if searchQuery} {@html highlightText(entry.content, searchQuery)} {:else} - {entry.content} + {@html highlightHashtags(entry.content)} {/if}

{#if entry.tags?.length > 0} @@ -510,12 +491,6 @@ - -
- - -
-
@@ -65,7 +64,7 @@ {#if unpinnedEntries.length > 0}
{#each unpinnedEntries as entry (entry.id)} - + {/each}
{/if} diff --git a/src/lib/utils/parseHashtags.ts b/src/lib/utils/parseHashtags.ts new file mode 100644 index 0000000..b5b3d3c --- /dev/null +++ b/src/lib/utils/parseHashtags.ts @@ -0,0 +1,31 @@ +/** + * Extract hashtags from text content + * Matches #word patterns, supporting alphanumeric and underscores + */ +export function parseHashtags(text: string): string[] { + const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; + const matches = text.matchAll(hashtagRegex); + const tags = new Set(); + + for (const match of matches) { + tags.add(match[1].toLowerCase()); + } + + return Array.from(tags); +} + +/** + * Highlight hashtags in text for display + * Returns HTML with hashtags wrapped in styled spans + */ +export function highlightHashtags(text: string): string { + const escaped = text + .replace(/&/g, '&') + .replace(//g, '>'); + + return escaped.replace( + /#([a-zA-Z][a-zA-Z0-9_]*)/g, + '#$1' + ); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 04b2adc..152a8fb 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -8,6 +8,7 @@ import { deleteImage as deleteImageFile } from '$lib/server/images/storage'; import { generateThumbnail } from '$lib/server/images/thumbnails'; +import { parseHashtags } from '$lib/utils/parseHashtags'; export const load: PageServerLoad = async ({ url }) => { const showCompleted = url.searchParams.get('showCompleted') === 'true'; @@ -53,6 +54,12 @@ export const actions: Actions = { type: type || 'thought' }); + // Parse hashtags from content and save them + const hashtags = parseHashtags(content); + if (hashtags.length > 0) { + tagRepository.updateEntryTags(entry.id, hashtags); + } + return { success: true, entryId: entry.id }; }, @@ -77,6 +84,7 @@ export const actions: Actions = { updates.title = title.toString().trim() || null; } + let contentChanged = false; const content = formData.get('content'); if (content !== null) { const contentStr = content.toString().trim(); @@ -84,6 +92,7 @@ export const actions: Actions = { return fail(400, { error: 'Content cannot be empty' }); } updates.content = contentStr; + contentChanged = true; } const type = formData.get('type'); @@ -106,6 +115,12 @@ export const actions: Actions = { entryRepository.update(id, updates); + // Re-parse hashtags if content changed + if (contentChanged && updates.content) { + const hashtags = parseHashtags(updates.content as string); + tagRepository.updateEntryTags(id, hashtags); + } + return { success: true }; }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2cdd0ae..b3e5656 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -72,7 +72,7 @@
- +