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:
@@ -5,8 +5,8 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import ImageGallery from './ImageGallery.svelte';
|
import ImageGallery from './ImageGallery.svelte';
|
||||||
import ImageUpload from './ImageUpload.svelte';
|
import ImageUpload from './ImageUpload.svelte';
|
||||||
import TagInput from './TagInput.svelte';
|
|
||||||
import { highlightText } from '$lib/utils/highlightText';
|
import { highlightText } from '$lib/utils/highlightText';
|
||||||
|
import { highlightHashtags } from '$lib/utils/parseHashtags';
|
||||||
|
|
||||||
interface EntryWithData extends Entry {
|
interface EntryWithData extends Entry {
|
||||||
images: Image[];
|
images: Image[];
|
||||||
@@ -15,11 +15,10 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entry: EntryWithData;
|
entry: EntryWithData;
|
||||||
availableTags: Tag[];
|
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { entry, availableTags, searchQuery = '' }: Props = $props();
|
let { entry, searchQuery = '' }: Props = $props();
|
||||||
|
|
||||||
// Expand/collapse state
|
// Expand/collapse state
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
let editContent = $state(entry.content);
|
let editContent = $state(entry.content);
|
||||||
let editType = $state(entry.type);
|
let editType = $state(entry.type);
|
||||||
let editDueDate = $state(entry.dueDate || '');
|
let editDueDate = $state(entry.dueDate || '');
|
||||||
let editTags = $state<Tag[]>(entry.tags || []);
|
|
||||||
|
|
||||||
// Sync edit state when entry changes (after invalidateAll)
|
// Sync edit state when entry changes (after invalidateAll)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -42,7 +40,6 @@
|
|||||||
editContent = entry.content;
|
editContent = entry.content;
|
||||||
editType = entry.type;
|
editType = entry.type;
|
||||||
editDueDate = entry.dueDate || '';
|
editDueDate = entry.dueDate || '';
|
||||||
editTags = entry.tags || [];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,22 +189,6 @@
|
|||||||
await invalidateAll();
|
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) {
|
async function handleCameraInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -317,7 +298,7 @@
|
|||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
{@html highlightText(entry.content, searchQuery)}
|
{@html highlightText(entry.content, searchQuery)}
|
||||||
{:else}
|
{:else}
|
||||||
{entry.content}
|
{@html highlightHashtags(entry.content)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{#if entry.tags?.length > 0}
|
{#if entry.tags?.length > 0}
|
||||||
@@ -510,12 +491,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
|
<label for="edit-type-{entry.id}" class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
|||||||
@@ -12,12 +12,11 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: EntryWithData[];
|
entries: EntryWithData[];
|
||||||
availableTags: Tag[];
|
|
||||||
filters: SearchFilters;
|
filters: SearchFilters;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { entries, availableTags, filters, searchQuery }: Props = $props();
|
let { entries, filters, searchQuery }: Props = $props();
|
||||||
|
|
||||||
// Apply filtering
|
// Apply filtering
|
||||||
let filteredEntries = $derived(filterEntries(entries, filters));
|
let filteredEntries = $derived(filterEntries(entries, filters));
|
||||||
@@ -44,7 +43,7 @@
|
|||||||
<!-- Flat list when filtering (no pinned/unpinned separation) -->
|
<!-- Flat list when filtering (no pinned/unpinned separation) -->
|
||||||
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
||||||
{#each filteredEntries as entry (entry.id)}
|
{#each filteredEntries as entry (entry.id)}
|
||||||
<EntryCard {entry} {availableTags} {searchQuery} />
|
<EntryCard {entry} {searchQuery} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -56,7 +55,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
||||||
{#each pinnedEntries as entry (entry.id)}
|
{#each pinnedEntries as entry (entry.id)}
|
||||||
<EntryCard {entry} {availableTags} {searchQuery} />
|
<EntryCard {entry} {searchQuery} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
{#if unpinnedEntries.length > 0}
|
{#if unpinnedEntries.length > 0}
|
||||||
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
<div class="divide-y divide-gray-100 md:divide-y-0 md:space-y-3">
|
||||||
{#each unpinnedEntries as entry (entry.id)}
|
{#each unpinnedEntries as entry (entry.id)}
|
||||||
<EntryCard {entry} {availableTags} {searchQuery} />
|
<EntryCard {entry} {searchQuery} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
31
src/lib/utils/parseHashtags.ts
Normal file
31
src/lib/utils/parseHashtags.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
return escaped.replace(
|
||||||
|
/#([a-zA-Z][a-zA-Z0-9_]*)/g,
|
||||||
|
'<span class="text-blue-600 font-medium">#$1</span>'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
deleteImage as deleteImageFile
|
deleteImage as deleteImageFile
|
||||||
} from '$lib/server/images/storage';
|
} from '$lib/server/images/storage';
|
||||||
import { generateThumbnail } from '$lib/server/images/thumbnails';
|
import { generateThumbnail } from '$lib/server/images/thumbnails';
|
||||||
|
import { parseHashtags } from '$lib/utils/parseHashtags';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const showCompleted = url.searchParams.get('showCompleted') === 'true';
|
const showCompleted = url.searchParams.get('showCompleted') === 'true';
|
||||||
@@ -53,6 +54,12 @@ export const actions: Actions = {
|
|||||||
type: type || 'thought'
|
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 };
|
return { success: true, entryId: entry.id };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -77,6 +84,7 @@ export const actions: Actions = {
|
|||||||
updates.title = title.toString().trim() || null;
|
updates.title = title.toString().trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contentChanged = false;
|
||||||
const content = formData.get('content');
|
const content = formData.get('content');
|
||||||
if (content !== null) {
|
if (content !== null) {
|
||||||
const contentStr = content.toString().trim();
|
const contentStr = content.toString().trim();
|
||||||
@@ -84,6 +92,7 @@ export const actions: Actions = {
|
|||||||
return fail(400, { error: 'Content cannot be empty' });
|
return fail(400, { error: 'Content cannot be empty' });
|
||||||
}
|
}
|
||||||
updates.content = contentStr;
|
updates.content = contentStr;
|
||||||
|
contentChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = formData.get('type');
|
const type = formData.get('type');
|
||||||
@@ -106,6 +115,12 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
entryRepository.update(id, updates);
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||||
<EntryList entries={data.entries} availableTags={data.allTags} {filters} {searchQuery} />
|
<EntryList entries={data.entries} {filters} {searchQuery} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QuickCapture />
|
<QuickCapture />
|
||||||
|
|||||||
Reference in New Issue
Block a user