mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
Streamline save button and add calendar view with search
- Remove redundant Save button, keep only Save & New - Add calendar view to timeline showing days with entries - Add search functionality with highlighted matches - Add date filtering by clicking calendar days - Show results count when filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { WeeklyReflection } from "@/components/WeeklyReflection";
|
||||
import { FilterPanel } from "@/components/FilterPanel";
|
||||
import { EntryRow } from "@/components/EntryRow";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CalendarView } from "@/components/CalendarView";
|
||||
import { Loader2, Search, Calendar, List, X } from "lucide-react";
|
||||
import type { EntryWithTags, FilterState } from "@/lib/types";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
|
||||
type ViewMode = "list" | "calendar";
|
||||
|
||||
export default function TimelinePage() {
|
||||
const [entries, setEntries] = useState<EntryWithTags[]>([]);
|
||||
const [filteredEntries, setFilteredEntries] = useState<EntryWithTags[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
moods: [],
|
||||
tagId: null,
|
||||
@@ -35,18 +42,41 @@ export default function TimelinePage() {
|
||||
loadEntries();
|
||||
}, []);
|
||||
|
||||
// Apply filters client-side
|
||||
// Get all dates that have entries
|
||||
const entryDates = useMemo(() => {
|
||||
return new Set(entries.map((e) => e.date));
|
||||
}, [entries]);
|
||||
|
||||
// Apply filters and search client-side
|
||||
useEffect(() => {
|
||||
let result = entries;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(e) =>
|
||||
e.text.toLowerCase().includes(query) ||
|
||||
e.tags.some((t) => t.name.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (selectedDate) {
|
||||
result = result.filter((e) => e.date === selectedDate);
|
||||
}
|
||||
|
||||
// Mood filter
|
||||
if (filters.moods.length > 0) {
|
||||
result = result.filter((e) => e.mood && filters.moods.includes(e.mood));
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (filters.tagId) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === filters.tagId));
|
||||
}
|
||||
|
||||
// Rough day filter
|
||||
if (filters.roughDay === true) {
|
||||
result = result.filter((e) => e.roughDay);
|
||||
} else if (filters.roughDay === false) {
|
||||
@@ -54,7 +84,20 @@ export default function TimelinePage() {
|
||||
}
|
||||
|
||||
setFilteredEntries(result);
|
||||
}, [entries, filters]);
|
||||
}, [entries, filters, searchQuery, selectedDate]);
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchQuery("");
|
||||
setSelectedDate(null);
|
||||
setFilters({ moods: [], tagId: null, roughDay: null });
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
searchQuery.trim() ||
|
||||
selectedDate ||
|
||||
filters.moods.length > 0 ||
|
||||
filters.tagId ||
|
||||
filters.roughDay !== null;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -70,20 +113,133 @@ export default function TimelinePage() {
|
||||
) : (
|
||||
<>
|
||||
<WeeklyReflection entries={entries} />
|
||||
|
||||
{/* Search and View Toggle */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={18}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search entries..."
|
||||
className="w-full pl-10 pr-10 py-2 bg-surface border border-border rounded-xl text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle and clear filters */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<List size={16} />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("calendar")}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${
|
||||
viewMode === "calendar"
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar size={16} />
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar View */}
|
||||
{viewMode === "calendar" && (
|
||||
<div className="mb-4">
|
||||
<CalendarView
|
||||
entryDates={entryDates}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={setSelectedDate}
|
||||
currentMonth={currentMonth}
|
||||
onChangeMonth={setCurrentMonth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filter indicator */}
|
||||
{selectedDate && (
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">Showing entries for:</span>
|
||||
<span className="px-2 py-1 bg-accent/20 text-accent rounded">
|
||||
{new Date(selectedDate + "T12:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedDate(null)}
|
||||
className="text-muted hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterPanel filters={filters} onChange={setFilters} />
|
||||
|
||||
{/* Results count */}
|
||||
{hasActiveFilters && (
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
{filteredEntries.length} {filteredEntries.length === 1 ? "entry" : "entries"} found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted">
|
||||
{entries.length === 0 ? (
|
||||
<p>No entries yet. Start your first check-in!</p>
|
||||
) : (
|
||||
<p>No entries match your filters.</p>
|
||||
<div>
|
||||
<p>No entries match your filters.</p>
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className="mt-2 text-accent hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredEntries.map((entry) => (
|
||||
<EntryRow key={entry.id} entry={entry} />
|
||||
<EntryRow key={entry.id} entry={entry} searchQuery={searchQuery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
155
src/components/CalendarView.tsx
Normal file
155
src/components/CalendarView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface CalendarViewProps {
|
||||
entryDates: Set<string>; // YYYY-MM-DD format
|
||||
selectedDate: string | null;
|
||||
onSelectDate: (date: string | null) => void;
|
||||
currentMonth: Date;
|
||||
onChangeMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
export function CalendarView({
|
||||
entryDates,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
currentMonth,
|
||||
onChangeMonth,
|
||||
}: CalendarViewProps) {
|
||||
const { days, monthLabel } = useMemo(() => {
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
|
||||
// First day of month
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const startDayOfWeek = firstDay.getDay();
|
||||
|
||||
// Days in month
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
// Build calendar grid
|
||||
const days: { date: string; day: number; isCurrentMonth: boolean; hasEntry: boolean }[] = [];
|
||||
|
||||
// Previous month padding
|
||||
const prevMonth = new Date(year, month, 0);
|
||||
const prevMonthDays = prevMonth.getDate();
|
||||
for (let i = startDayOfWeek - 1; i >= 0; i--) {
|
||||
const day = prevMonthDays - i;
|
||||
const date = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
// Current month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: true, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const remaining = 42 - days.length; // 6 rows * 7 days
|
||||
for (let day = 1; day <= remaining; day++) {
|
||||
const nextMonth = new Date(year, month + 2, 0);
|
||||
const date = `${nextMonth.getFullYear()}-${String(month + 2).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) });
|
||||
}
|
||||
|
||||
const monthLabel = firstDay.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
||||
|
||||
return { days, monthLabel };
|
||||
}, [currentMonth, entryDates]);
|
||||
|
||||
const goToPrevMonth = () => {
|
||||
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
onChangeMonth(new Date());
|
||||
onSelectDate(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{monthLabel}</span>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
className="p-1 text-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="text-center text-xs text-muted py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map(({ date, day, isCurrentMonth, hasEntry }) => {
|
||||
const isSelected = selectedDate === date;
|
||||
const isToday = date === new Date().toISOString().split("T")[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={date}
|
||||
onClick={() => onSelectDate(isSelected ? null : date)}
|
||||
className={`
|
||||
relative aspect-square flex items-center justify-center text-sm rounded-lg transition-colors
|
||||
${!isCurrentMonth ? "text-muted/30" : ""}
|
||||
${isSelected ? "bg-accent text-white" : "hover:bg-border"}
|
||||
${isToday && !isSelected ? "ring-1 ring-accent" : ""}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
{hasEntry && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-accent rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-muted">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-accent rounded-full" />
|
||||
<span>Has entry</span>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<button
|
||||
onClick={() => onSelectDate(null)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,45 +99,22 @@ export function EntryForm({ date, entryId, isNewEntry = false, onSaved }: EntryF
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save buttons */}
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
{isNewEntry ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSaveAndNew}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Plus size={16} />
|
||||
)}
|
||||
Save & New
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-surface border border-border text-foreground rounded-lg hover:border-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={isNewEntry ? handleSaveAndNew : handleSave}
|
||||
disabled={isSaving || !hasContent}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : isNewEntry ? (
|
||||
<Plus size={16} />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
{isNewEntry ? "Save & New" : "Save"}
|
||||
</button>
|
||||
|
||||
{/* Save status */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted ml-auto">
|
||||
|
||||
@@ -8,9 +8,27 @@ import type { EntryWithTags } from "@/lib/types";
|
||||
|
||||
interface EntryRowProps {
|
||||
entry: EntryWithTags;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function EntryRow({ entry }: EntryRowProps) {
|
||||
function highlightText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-accent/30 text-foreground rounded px-0.5">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function EntryRow({ entry, searchQuery = "" }: EntryRowProps) {
|
||||
// Truncate text to one line preview
|
||||
const preview = entry.text.length > 80 ? entry.text.slice(0, 80) + "..." : entry.text;
|
||||
|
||||
@@ -31,10 +49,12 @@ export function EntryRow({ entry }: EntryRowProps) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-foreground truncate">{preview}</p>
|
||||
<p className="text-foreground truncate">
|
||||
{highlightText(preview, searchQuery)}
|
||||
</p>
|
||||
{entry.tags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<TagChips tags={entry.tags} />
|
||||
<TagChips tags={entry.tags} highlightQuery={searchQuery} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -120,9 +120,27 @@ export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps)
|
||||
interface TagChipsProps {
|
||||
tags: { id: string; name: string }[];
|
||||
maxDisplay?: number;
|
||||
highlightQuery?: string;
|
||||
}
|
||||
|
||||
export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
||||
function highlightTagText(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={i} className="bg-accent/40 text-foreground rounded">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function TagChips({ tags, maxDisplay = 2, highlightQuery = "" }: TagChipsProps) {
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
const displayed = tags.slice(0, maxDisplay);
|
||||
@@ -135,7 +153,7 @@ export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) {
|
||||
key={tag.id}
|
||||
className="px-2 py-0.5 bg-surface text-muted text-xs rounded"
|
||||
>
|
||||
{tag.name}
|
||||
{highlightTagText(tag.name, highlightQuery)}
|
||||
</span>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user