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