diff --git a/app/api/countdowns/route.ts b/app/api/countdowns/route.ts new file mode 100644 index 0000000..1d142c4 --- /dev/null +++ b/app/api/countdowns/route.ts @@ -0,0 +1,97 @@ +export const dynamic = 'force-dynamic' +import { NextResponse } from 'next/server' +import * as fs from 'fs' + +const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp' +const COUNTDOWNS_PATH = `${DATA_DIR}/tablet-countdowns.json` + +export interface Countdown { + id: string + label: string + date: string // YYYY-MM-DD + emoji?: string + color?: string // hex or data-* token name (e.g. "data-rose") + note?: string + createdAt: string + updatedAt: string +} + +const DEFAULT_COUNTDOWNS: Countdown[] = [ + { + id: 'tokyo', + label: 'Токио', + date: '2026-10-15', + emoji: '🗼', + color: 'data-rose', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, +] + +function load(): Countdown[] { + try { + if (fs.existsSync(COUNTDOWNS_PATH)) { + return JSON.parse(fs.readFileSync(COUNTDOWNS_PATH, 'utf-8')) + } + // первая загрузка — создать дефолтный файл + save(DEFAULT_COUNTDOWNS) + return DEFAULT_COUNTDOWNS + } catch { + return [] + } +} + +function save(list: Countdown[]) { + try { + fs.writeFileSync(COUNTDOWNS_PATH, JSON.stringify(list, null, 2)) + } catch {} +} + +export async function GET() { + const all = load() + // сортируем по дате возрастания + const sorted = [...all].sort((a, b) => a.date.localeCompare(b.date)) + return NextResponse.json({ countdowns: sorted }) +} + +export async function POST(req: Request) { + const body = await req.json() + if (!body.label || !body.date) { + return NextResponse.json({ error: 'label_and_date_required' }, { status: 400 }) + } + const list = load() + const cd: Countdown = { + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), + label: String(body.label).slice(0, 60), + date: String(body.date).slice(0, 10), + emoji: body.emoji?.slice(0, 4) || undefined, + color: body.color || undefined, + note: body.note?.slice(0, 200) || undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + list.push(cd) + save(list) + return NextResponse.json({ countdown: cd }) +} + +export async function PUT(req: Request) { + const body = await req.json() + const { id, ...updates } = body + if (!id) return NextResponse.json({ error: 'id_required' }, { status: 400 }) + const list = load() + const idx = list.findIndex(c => c.id === id) + if (idx < 0) return NextResponse.json({ error: 'not_found' }, { status: 404 }) + list[idx] = { ...list[idx], ...updates, id, updatedAt: new Date().toISOString() } + save(list) + return NextResponse.json({ countdown: list[idx] }) +} + +export async function DELETE(req: Request) { + const url = new URL(req.url) + const id = url.searchParams.get('id') + if (!id) return NextResponse.json({ error: 'id_required' }, { status: 400 }) + const list = load().filter(c => c.id !== id) + save(list) + return NextResponse.json({ ok: true }) +} diff --git a/app/globals.css b/app/globals.css index 5c4266c..9ca48ca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,11 +26,32 @@ --text-secondary: rgba(255, 255, 255, 0.6); --text-tertiary: rgba(255, 255, 255, 0.38); - /* Accents */ + /* Accents — UI */ --accent: #818cf8; + --accent-strong: #6366f1; --accent-secondary: #22d3ee; --accent-glow: rgba(129, 140, 248, 0.22); + /* Data palette — semantic, theme-aware */ + --data-cool: #38bdf8; /* небо, влажность, охлаждение */ + --data-info: #60a5fa; /* ссылки, second-accent */ + --data-good: #34d399; /* успех, здоровье, streak */ + --data-warm: #fbbf24; /* свет, тепло, утро */ + --data-hot: #fb923c; /* обострение, urgent-minor */ + --data-danger: #f87171; /* критичное, истёк, ошибка */ + --data-rose: #f472b6; /* Света, романтика */ + --data-violet: #a78bfa; /* Cosmo, индиго-вариант */ + --data-mood: #8b5cf6; /* секреты, события 2-го уровня */ + + /* Soft backgrounds for data (color-mix with surface-2) */ + --data-cool-bg: color-mix(in srgb, var(--data-cool) 12%, var(--surface-2)); + --data-good-bg: color-mix(in srgb, var(--data-good) 12%, var(--surface-2)); + --data-warm-bg: color-mix(in srgb, var(--data-warm) 12%, var(--surface-2)); + --data-hot-bg: color-mix(in srgb, var(--data-hot) 14%, var(--surface-2)); + --data-danger-bg: color-mix(in srgb, var(--data-danger) 12%, var(--surface-2)); + --data-rose-bg: color-mix(in srgb, var(--data-rose) 12%, var(--surface-2)); + --data-violet-bg: color-mix(in srgb, var(--data-violet) 12%, var(--surface-2)); + /* Brand gradients */ --gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6); --gradient-warm: linear-gradient(135deg, #f59e0b, #ef4444); @@ -54,6 +75,18 @@ --radius-lg: 22px; --radius-xl: 28px; + /* Layout rhythm */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 22px; + --space-6: 28px; + + /* Touch */ + --touch-min: 44px; + --touch-comfy: 56px; + /* Legacy aliases */ --sidebar-bg: var(--bg); --card-bg: var(--surface-1); @@ -84,9 +117,30 @@ --text-tertiary: rgba(15, 20, 40, 0.38); --accent: #5b63e0; + --accent-strong: #4f46e5; --accent-secondary: #0891b2; --accent-glow: rgba(91, 99, 224, 0.14); + /* Data palette — slightly darker in light theme for contrast */ + --data-cool: #0284c7; + --data-info: #2563eb; + --data-good: #059669; + --data-warm: #d97706; + --data-hot: #ea580c; + --data-danger: #dc2626; + --data-rose: #db2777; + --data-violet: #7c3aed; + --data-mood: #6d28d9; + + /* Soft backgrounds — lighter mix for light theme */ + --data-cool-bg: color-mix(in srgb, var(--data-cool) 9%, var(--surface-1)); + --data-good-bg: color-mix(in srgb, var(--data-good) 9%, var(--surface-1)); + --data-warm-bg: color-mix(in srgb, var(--data-warm) 9%, var(--surface-1)); + --data-hot-bg: color-mix(in srgb, var(--data-hot) 10%, var(--surface-1)); + --data-danger-bg: color-mix(in srgb, var(--data-danger) 9%, var(--surface-1)); + --data-rose-bg: color-mix(in srgb, var(--data-rose) 9%, var(--surface-1)); + --data-violet-bg: color-mix(in srgb, var(--data-violet) 9%, var(--surface-1)); + --shadow-sm: 0 1px 2px rgba(15, 20, 40, 0.05); --shadow-md: 0 2px 6px rgba(15, 20, 40, 0.06), 0 12px 28px -8px rgba(15, 20, 40, 0.1); --shadow-lg: 0 4px 10px rgba(15, 20, 40, 0.06), 0 24px 60px -12px rgba(15, 20, 40, 0.14); @@ -112,6 +166,41 @@ html, body { #__next, main { height: 100%; } +/* Better tabular numbers across the app */ +.num, .num-display { + font-variant-numeric: tabular-nums slashed-zero; + font-feature-settings: "tnum", "zero"; +} +.num-display { + letter-spacing: -0.02em; + font-weight: 800; + line-height: 0.95; +} + +/* —————————————————————————————— + Grain overlay + ——— subtle SVG-noise; cheap, no image asset needed + —————————————————————————————— */ +.grain { + position: relative; +} +.grain::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + opacity: 0.035; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 120px 120px; +} +.light .grain::after { + opacity: 0.05; + mix-blend-mode: multiply; + filter: invert(1); +} + /* —————————————————————————————— Aurora —————————————————————————————— */ @@ -178,6 +267,23 @@ html, body { box-shadow: var(--shadow-lg); } +/* Focus-card styling — for the context-aware hero */ +.focus-card { + position: relative; + background: var(--surface-1); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + overflow: hidden; +} +.focus-card-accent::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at top right, var(--accent-glow), transparent 60%); + pointer-events: none; +} + .glass-card { background: var(--surface-1); border: 1px solid var(--border-subtle); @@ -186,6 +292,32 @@ html, body { } .glass-card:hover { background: var(--surface-hover); } +/* Hairline divider helper */ +.divider { + height: 1px; + width: 100%; + background: var(--hairline); +} + +/* Chip / hit-zone — wraps small icons into 44px tap surface */ +.hit-zone { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--touch-min); + min-height: var(--touch-min); + border-radius: 12px; +} + +/* Small uppercase label */ +.eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-tertiary); +} + .gradient-text { background: var(--gradient-primary); -webkit-background-clip: text; @@ -205,7 +337,8 @@ button { } button:focus-visible { outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: 3px; + border-radius: 14px; } ::-webkit-scrollbar { width: 6px; height: 6px; } diff --git a/app/page.tsx b/app/page.tsx index b02f906..8b3fb24 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,9 +14,13 @@ import WeatherAnimation from '@/components/WeatherAnimation' import VoiceOverlay from '@/components/VoiceOverlay' import TimerWidget from '@/components/TimerWidget' import TimerHomeWidget from '@/components/TimerHomeWidget' +import FocusCard from '@/components/FocusCard' +import CountdownCard from '@/components/CountdownCard' type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings' +const TAB_ORDER: Tab[] = ['home', 'devices', 'calendar', 'notes', 'settings'] + interface WeatherData { temp: string desc: string @@ -311,7 +315,7 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: { } return ( -
+
go(-1)} + aria-label="Предыдущий день" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', - width: 36, height: 36, borderRadius: 12, + width: 48, height: 48, borderRadius: 14, background: 'var(--surface-2)', border: '1px solid var(--border-subtle)', - color: 'var(--text-secondary)', zIndex: 2, + color: 'var(--text-secondary)', zIndex: 2, fontSize: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', }} >‹ @@ -357,11 +362,12 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: { {canNext && ( @@ -422,7 +428,8 @@ function WeatherDayModal({ day, days, current, onClose, onChange }: { )}
+ {/* ───── Row 4: Countdown ───── */} +
+ +
{/* место под будущий виджет */} +
+ {/* Weather day detail modal */} {selectedDay && ( (null) + const handlePointerDown = (e: React.PointerEvent) => { + // Only primary pointer / touch + if (e.pointerType === 'mouse' && e.button !== 0) return + swipeStart.current = { x: e.clientX, y: e.clientY, t: Date.now(), id: e.pointerId } + } + const handlePointerUp = (e: React.PointerEvent) => { + const s = swipeStart.current + swipeStart.current = null + if (!s || s.id !== e.pointerId) return + const dx = e.clientX - s.x + const dy = e.clientY - s.y + const dt = Date.now() - s.t + // Conditions: fast enough, mostly horizontal, big enough + const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.6 + const isLong = Math.abs(dx) > 90 + const isFast = dt < 600 || Math.abs(dx) > 160 + if (!(isHorizontal && isLong && isFast)) return + // Don't steal swipe from elements that draggable-own (notes swipe-to-delete, weather modal, timer cards). + const target = e.target as HTMLElement | null + if (target?.closest('[data-swipe-ignore]')) return + const idx = TAB_ORDER.indexOf(tab) + if (idx < 0) return + const nextIdx = dx < 0 ? idx + 1 : idx - 1 + if (nextIdx < 0 || nextIdx >= TAB_ORDER.length) return + setTab(TAB_ORDER[nextIdx]) + } + const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || [] const getDeviceState = (haKey?: string): boolean => { if (!haKey || !haStates[haKey]) return false @@ -1072,7 +1102,12 @@ function HomePageInner() { }} /> -
+
{ swipeStart.current = null }} + style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, position: 'relative', zIndex: 1 }} + > diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx index a8039d6..251e1ee 100644 --- a/components/CalendarTab.tsx +++ b/components/CalendarTab.tsx @@ -57,7 +57,7 @@ function AddEventModal({ defaultDate, onClose, onSaved }: { defaultDate: string; } return ( -
Новое событие -
@@ -301,7 +301,7 @@ function EventDetailModal({ event, onClose, onDelete, onUpdate }: { } return ( -
+
)} -
@@ -543,7 +543,7 @@ function DayEventsModal({ day, month, year, events, onClose, onSelect }: { const label = date.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' }) return ( -
+
- {MONTHS[month]} {year} -
@@ -733,15 +733,19 @@ export default function CalendarTab() { ) })} -
@@ -148,6 +159,7 @@ export default function NotesTab() { {/* Delete reveal layer */} @@ -260,13 +272,16 @@ export default function NotesTab() { fontFamily: 'inherit', flex: 1, minWidth: 0, }} /> -
@@ -414,6 +429,7 @@ export default function NotesTab() { {confirmDelete && (
setConfirmDelete(null)} + data-swipe-ignore style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(12px)', diff --git a/components/TimerHomeWidget.tsx b/components/TimerHomeWidget.tsx index 3d436ee..bd225fb 100644 --- a/components/TimerHomeWidget.tsx +++ b/components/TimerHomeWidget.tsx @@ -125,15 +125,15 @@ export default function TimerHomeWidget() { onClick={() => setModal({ type: 'create' })} aria-label="Новый таймер" style={{ - width: 32, height: 32, borderRadius: 10, - background: 'color-mix(in srgb, #818cf8 12%, var(--surface-2))', - border: '1px solid color-mix(in srgb, #818cf8 30%, var(--border-subtle))', - color: '#a5b4fc', + width: 44, height: 44, borderRadius: 14, + background: 'color-mix(in srgb, var(--accent) 14%, var(--surface-2))', + border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle))', + color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', }} > - +
diff --git a/components/TimerModal.tsx b/components/TimerModal.tsx index 8c3cd51..07ce1ef 100644 --- a/components/TimerModal.tsx +++ b/components/TimerModal.tsx @@ -69,6 +69,7 @@ export default function TimerModal({ mode, onClose }: TimerModalProps) { exit={{ opacity: 0 }} transition={{ duration: 0.2 }} onClick={onClose} + data-swipe-ignore style={{ position: 'fixed', inset: 0, zIndex: 250, background: 'rgba(5, 5, 15, 0.72)', diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 3b9b0e8..934e745 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -86,32 +86,41 @@ export default function TopBar({ sensors, haConnected }: TopBarProps) {
{/* Right: sensors + weather */} -
- {/* HA status */} -
+
+ {/* HA status — 44px hit-zone wrapping 14px dot */} +
+
+
{sensors && (
- - + + {sensors.temperature}° - - + + {sensors.humidity}% - - + + {sensors.pm25}
diff --git a/components/TransportWidget.tsx b/components/TransportWidget.tsx index f8e6a5c..94c1419 100644 --- a/components/TransportWidget.tsx +++ b/components/TransportWidget.tsx @@ -21,10 +21,12 @@ const DIRECTIONS: Direction[] = [ { stopId: '16354', short: 'Дыбенко', sub: 'от центра' }, ] +// Маршрут-специфичные цвета — соответствуют реальной окраске маршрутов +// (но в правильной семантике: good=23, info=27, danger=39) const ROUTES: { num: string; color: string; bg: string }[] = [ - { num: '23', color: '#10b981', bg: 'linear-gradient(135deg, #10b981, #059669)' }, - { num: '27', color: '#3b82f6', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' }, - { num: '39', color: '#ef4444', bg: 'linear-gradient(135deg, #ef4444, #dc2626)' }, + { num: '23', color: 'var(--data-good)', bg: 'linear-gradient(135deg, var(--data-good), color-mix(in srgb, var(--data-good) 75%, black))' }, + { num: '27', color: 'var(--data-info)', bg: 'linear-gradient(135deg, var(--data-info), color-mix(in srgb, var(--data-info) 75%, black))' }, + { num: '39', color: 'var(--data-danger)', bg: 'linear-gradient(135deg, var(--data-danger), color-mix(in srgb, var(--data-danger) 75%, black))' }, ] function Cell({ arrivals, color }: { arrivals: Arrival[]; color: string }) { @@ -189,7 +191,7 @@ export default function TransportWidget() { }}>