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 <noreply@anthropic.com>
This commit is contained in:
Thomas Richter
2026-02-01 21:59:04 +01:00
parent 21a11bbb22
commit ce07d79652
5 changed files with 54 additions and 34 deletions

View File

@@ -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<Tag[]>(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}
</p>
{#if entry.tags?.length > 0}
@@ -510,12 +491,6 @@
</div>
</div>
<!-- Tags section -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tags</label>
<TagInput {availableTags} selectedTags={editTags} onchange={handleTagsChange} />
</div>
<div class="flex items-center justify-between">
<div>
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"

View File

@@ -12,12 +12,11 @@
interface Props {
entries: EntryWithData[];
availableTags: Tag[];
filters: SearchFilters;
searchQuery: string;
}
let { entries, availableTags, filters, searchQuery }: Props = $props();
let { entries, filters, searchQuery }: Props = $props();
// Apply filtering
let filteredEntries = $derived(filterEntries(entries, filters));
@@ -44,7 +43,7 @@
<!-- Flat list when filtering (no pinned/unpinned separation) -->
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
{#each filteredEntries as entry (entry.id)}
<EntryCard {entry} {availableTags} {searchQuery} />
<EntryCard {entry} {searchQuery} />
{/each}
</div>
{:else}
@@ -56,7 +55,7 @@
</h2>
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
{#each pinnedEntries as entry (entry.id)}
<EntryCard {entry} {availableTags} {searchQuery} />
<EntryCard {entry} {searchQuery} />
{/each}
</div>
</div>
@@ -65,7 +64,7 @@
{#if unpinnedEntries.length > 0}
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
{#each unpinnedEntries as entry (entry.id)}
<EntryCard {entry} {availableTags} {searchQuery} />
<EntryCard {entry} {searchQuery} />
{/each}
</div>
{/if}

View File

@@ -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<string>();
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return escaped.replace(
/#([a-zA-Z][a-zA-Z0-9_]*)/g,
'<span class="text-blue-600 font-medium">#$1</span>'
);
}