mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
A calm, private gratitude and mood log built with Next.js 16, TypeScript, Tailwind CSS, and SQLite/Drizzle ORM. Features: - Quick check-in with autosave (800ms debounce) - Optional mood selector (5 levels) with accessibility labels - Optional tags with tap-to-add from recent - Timeline with weekly reflection card - Filters by mood, tag, and rough day - Export to Markdown and JSON - Dark mode default - Delete with undo toast - Docker deployment ready Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
147 lines
4.0 KiB
TypeScript
147 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { X, Plus } from "lucide-react";
|
|
import type { Tag } from "@/lib/db/schema";
|
|
|
|
interface TagInputProps {
|
|
selectedTags: string[];
|
|
onAddTag: (name: string) => void;
|
|
onRemoveTag: (name: string) => void;
|
|
}
|
|
|
|
export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps) {
|
|
const [recentTags, setRecentTags] = useState<Tag[]>([]);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [showInput, setShowInput] = useState(false);
|
|
|
|
useEffect(() => {
|
|
async function loadRecentTags() {
|
|
try {
|
|
const res = await fetch("/api/tags?limit=10");
|
|
if (res.ok) {
|
|
const tags = await res.json();
|
|
setRecentTags(tags);
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
loadRecentTags();
|
|
}, []);
|
|
|
|
const availableTags = recentTags.filter((t) => !selectedTags.includes(t.name));
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = inputValue.trim();
|
|
if (trimmed) {
|
|
onAddTag(trimmed);
|
|
setInputValue("");
|
|
setShowInput(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Selected tags */}
|
|
{selectedTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{selectedTags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center gap-1 px-3 py-1 bg-accent/20 text-accent rounded-full text-sm"
|
|
>
|
|
{tag}
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemoveTag(tag)}
|
|
className="hover:bg-accent/30 rounded-full p-0.5"
|
|
aria-label={`Remove ${tag}`}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent tags to tap */}
|
|
{availableTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{availableTags.slice(0, 6).map((tag) => (
|
|
<button
|
|
key={tag.id}
|
|
type="button"
|
|
onClick={() => onAddTag(tag.name)}
|
|
className="px-3 py-1 bg-surface border border-border rounded-full text-sm text-muted hover:text-foreground hover:border-muted transition-colors"
|
|
>
|
|
+ {tag.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add custom tag */}
|
|
{showInput ? (
|
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
placeholder="Type a tag..."
|
|
className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
|
autoFocus
|
|
onBlur={() => {
|
|
if (!inputValue.trim()) setShowInput(false);
|
|
}}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="px-3 py-2 bg-accent text-white rounded-lg text-sm"
|
|
>
|
|
Add
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowInput(true)}
|
|
className="inline-flex items-center gap-1 px-3 py-1 text-sm text-muted hover:text-foreground transition-colors"
|
|
>
|
|
<Plus size={14} />
|
|
Add tag
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TagChipsProps {
|
|
tags: { id: string; name: string }[];
|
|
maxDisplay?: number;
|
|
}
|
|
|
|
export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
|
if (tags.length === 0) return null;
|
|
|
|
const displayed = tags.slice(0, maxDisplay);
|
|
const remaining = tags.length - maxDisplay;
|
|
|
|
return (
|
|
<div className="flex gap-1">
|
|
{displayed.map((tag) => (
|
|
<span
|
|
key={tag.id}
|
|
className="px-2 py-0.5 bg-surface text-muted text-xs rounded"
|
|
>
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
{remaining > 0 && (
|
|
<span className="px-2 py-0.5 text-muted text-xs">+{remaining}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|