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>
191 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|