mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-25 05:41:38 +08:00
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>
This commit is contained in:
146
src/components/TagInput.tsx
Normal file
146
src/components/TagInput.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user