My-Weekly-ToDo-List/src/components/FontPicker.tsx
mARTin 163e9c1a07 feat: major update — mobile fix, auth decoupling, CalDAV perf, projects, fonts, quotes
Mobile fixes:
- Add viewport meta tag (was missing, causing broken mobile rendering)
- Make header nav controls visible on mobile (opacity-0 group-hover was invisible on touch)
- Add touch swipe gestures for day navigation on mobile
- Fix viewDays responsive override after profile load

Bug fixes:
- Fix goal save (WeeklyView used POST but route only had GET/PUT; fix to use PUT, add POST alias)
- Fix body key mismatch (goal → text) in goal save request

Auth & identity (Task 1):
- Add accountNumber (auto-increment) to User model for stable identity
- Fix OAuth flows: pass user CUID in state instead of email
- Google/Outlook callbacks now look up users by ID, not email
- Non-Gmail users can now connect Google Calendar/Tasks

CalDAV performance (Task 5):
- Embed CalDAV object URL in Apple event IDs (caldav::<url>::<uid> format)
- Delete/update now use O(1) direct URL access instead of O(n) full calendar scan
- Optimistic UI removal on delete (no blocking force-refresh)
- Legacy fallback for old-format IDs during transition

Apple Calendar data (Task 2):
- Pass URL, location, recurringEventId, isRecurring through full pipeline
- Add url, recurringEventId, isRecurring to CachedCalendarEvent schema
- Calendar cache now reads/writes all new fields

Projects (Task 6):
- New Project model (name, icon, color, description, order)
- CRUD API at /api/tasks/projects
- Tasks now support optional projectId with cascading nullify

Quotes (Task 4):
- Curated local quote database (50 quotes, DE+EN) with tag filtering
- Preset quote source APIs (ZenQuotes, Quotable, Forismatic, Type.fit)

Fonts (Task 7):
- FontPicker component with searchable dropdown and live preview
- /api/fonts endpoint (Google Fonts API proxy with 24h cache, fallback to 40 popular fonts)

Quick Settings (Task 8):
- QuickSettingsSidebar component (font size, spacing, show completed, start day, show lines)
- New user preferences: showCompletedTasks, showLines, startDayOffset, quoteSourceUrls

v1.9.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:55:49 +01:00

191 lines
7.5 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, Search } from "lucide-react";
const POPULAR_FONTS = [
"Inter", "Roboto", "Open Sans", "Lato", "Montserrat", "Oswald",
"Raleway", "Playfair Display", "Merriweather", "Nunito",
"Dancing Script", "Pacifico", "Poppins", "Source Sans Pro",
"Ubuntu", "Rubik", "Work Sans", "Quicksand", "Josefin Sans",
"Libre Baskerville", "Crimson Text", "Bitter", "Archivo",
"DM Sans", "Space Grotesk", "Outfit", "Sora", "Caveat",
"Comfortaa", "Barlow", "Karla", "Manrope", "Lexend",
"Roboto Slab", "PT Serif", "Noto Sans", "Fira Sans",
"IBM Plex Sans", "Cabin", "Inconsolata",
];
interface FontPickerProps {
value: string;
onChange: (fontName: string) => void;
darkMode?: boolean;
}
export default function FontPicker({ value, onChange, darkMode }: FontPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [allFonts, setAllFonts] = useState<string[]>(POPULAR_FONTS);
const [loadedFonts, setLoadedFonts] = useState<Set<string>>(new Set(["Inter"]));
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Try to fetch from Google Fonts API for extended list
useEffect(() => {
const fetchFonts = async () => {
try {
const res = await fetch("/api/fonts");
if (res.ok) {
const data = await res.json();
if (data.fonts?.length > 0) {
setAllFonts(data.fonts.map((f: any) => f.value || f.name || f));
}
}
} catch {
// Keep popular fonts as fallback
}
};
fetchFonts();
}, []);
// Load font for preview
const loadFont = (fontName: string) => {
if (loadedFonts.has(fontName) || fontName === "Inter") return;
const id = `font-preview-${fontName.replace(/\s+/g, "-")}`;
if (!document.getElementById(id)) {
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, "+")}:wght@400;700&display=swap`;
document.head.appendChild(link);
}
setLoadedFonts((prev) => new Set(prev).add(fontName));
};
// Load selected font
useEffect(() => {
if (value) loadFont(value);
}, [value]);
// Close on click outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
// Focus input on open
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const filtered = query
? allFonts.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
: allFonts;
const bg = darkMode ? "#1f2937" : "#fff";
const border = darkMode ? "#374151" : "#e5e7eb";
const text = darkMode ? "#e5e7eb" : "#333";
const hoverBg = darkMode ? "#374151" : "#f0f9ff";
return (
<div ref={containerRef} style={{ position: "relative" }}>
<button
onClick={() => setIsOpen(!isOpen)}
style={{
fontFamily: value,
display: "flex",
alignItems: "center",
gap: "4px",
padding: "4px 8px",
border: `1px solid ${border}`,
borderRadius: "6px",
background: bg,
color: text,
cursor: "pointer",
fontSize: "0.8rem",
width: "100%",
justifyContent: "space-between",
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{value || "Select font"}
</span>
<ChevronDown size={14} />
</button>
{isOpen && (
<div
style={{
position: "absolute",
zIndex: 1000,
background: bg,
border: `1px solid ${border}`,
borderRadius: "8px",
width: "260px",
boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
top: "100%",
left: 0,
marginTop: "4px",
}}
>
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${border}`, padding: "6px 8px", gap: "6px" }}>
<Search size={14} style={{ color: "#999", flexShrink: 0 }} />
<input
ref={inputRef}
type="text"
placeholder="Search fonts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{
padding: "4px",
width: "100%",
border: "none",
outline: "none",
background: "transparent",
color: text,
fontSize: "0.8rem",
}}
/>
</div>
<div style={{ maxHeight: "250px", overflowY: "auto" }}>
{filtered.slice(0, 50).map((font) => {
loadFont(font);
return (
<div
key={font}
onClick={() => {
onChange(font);
setIsOpen(false);
setQuery("");
}}
onMouseEnter={() => loadFont(font)}
style={{
padding: "6px 12px",
cursor: "pointer",
fontFamily: font,
fontSize: "0.85rem",
color: text,
background: font === value ? hoverBg : "transparent",
borderLeft: font === value ? "3px solid #0ea5e9" : "3px solid transparent",
}}
>
{font}
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: "12px", textAlign: "center", color: "#999", fontSize: "0.8rem" }}>
No fonts found
</div>
)}
</div>
</div>
)}
</div>
);
}