Files
quietthanks/src/components/TagInput.tsx
Gemini Agent 5555c1e6b5 Initial commit: Quiet Thanks gratitude app
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>
2026-01-24 01:57:20 +00:00

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>
);
}