Files
smart-home-tablet/components/NotesTab.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

507 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { Plus, X, Trash2, ShoppingCart, FileText, Check, Circle, Calendar as CalendarIcon } from 'lucide-react'
interface NoteItem {
id: string
text: string
done: boolean
}
interface Note {
id: string
type: 'note' | 'shopping'
title: string
items?: NoteItem[]
text?: string
color: string
pinDate: string | null
createdAt: string
updatedAt: string
}
// NOTE: заметки сохраняют свой цвет на всё время жизни —
// храним hex, но генерим их из theme-aware CSS-переменных через computed style.
// Для совместимости с существующими заметками оставляем hex-палитру,
// но подобранную под новые data-токены (dark theme).
const NOTE_COLORS = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#38bdf8', '#a78bfa']
export default function NotesTab() {
const [notes, setNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
const [activeNote, setActiveNote] = useState<Note | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newItemText, setNewItemText] = useState('')
const [confirmDelete, setConfirmDelete] = useState<Note | null>(null)
const load = useCallback(async () => {
try {
const r = await fetch('/api/notes')
const d = await r.json()
setNotes(d.notes || [])
} catch {}
finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
const createNote = async (type: 'note' | 'shopping') => {
const title = type === 'shopping' ? 'Список покупок' : 'Новая заметка'
const color = NOTE_COLORS[notes.length % NOTE_COLORS.length]
try {
const r = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, title, color }),
})
const d = await r.json()
setNotes(prev => [d.note, ...prev])
setActiveNote(d.note)
setShowCreate(false)
} catch {}
}
const updateNote = async (id: string, updates: Partial<Note>) => {
try {
const r = await fetch('/api/notes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...updates }),
})
const d = await r.json()
setNotes(prev => prev.map(n => n.id === id ? d.note : n))
if (activeNote?.id === id) setActiveNote(d.note)
} catch {}
}
const deleteNote = async (id: string) => {
try {
await fetch(`/api/notes?id=${id}`, { method: 'DELETE' })
setNotes(prev => prev.filter(n => n.id !== id))
if (activeNote?.id === id) setActiveNote(null)
} catch {}
}
const addItem = async () => {
if (!newItemText.trim() || !activeNote) return
const items = [...(activeNote.items || []), { id: Date.now().toString(36), text: newItemText.trim(), done: false }]
await updateNote(activeNote.id, { items })
setNewItemText('')
}
const toggleItem = async (itemId: string) => {
if (!activeNote) return
const items = (activeNote.items || []).map(i => i.id === itemId ? { ...i, done: !i.done } : i)
await updateNote(activeNote.id, { items })
}
const removeItem = async (itemId: string) => {
if (!activeNote) return
const items = (activeNote.items || []).filter(i => i.id !== itemId)
await updateNote(activeNote.id, { items })
}
return (
<div style={{ flex: 1, display: 'flex', overflow: 'hidden', padding: '16px 24px 24px', gap: 16 }}>
{/* Left: notes list */}
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>Заметки</h2>
<button
onClick={() => setShowCreate(v => !v)}
aria-label={showCreate ? 'Отмена' : 'Создать заметку'}
style={{
width: 44, height: 44, borderRadius: 14,
background: showCreate
? 'var(--surface-2)'
: 'color-mix(in srgb, var(--accent) 16%, var(--surface-2))',
border: showCreate
? '1px solid var(--border-subtle)'
: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border-subtle))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: showCreate ? 'var(--text-secondary)' : 'var(--accent)',
}}>
{showCreate ? <X size={18} /> : <Plus size={18} />}
</button>
</div>
{/* Create new */}
{showCreate && (
<div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<button onClick={() => createNote('note')} style={{
flex: 1, padding: '14px 12px', borderRadius: 14,
background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(129,140,248,0.15)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
color: '#a5b4fc', fontSize: 12, fontWeight: 600,
}}>
<FileText size={20} />
Заметка
</button>
<button onClick={() => createNote('shopping')} style={{
flex: 1, padding: '14px 12px', borderRadius: 14,
background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.15)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6,
color: '#34d399', fontSize: 12, fontWeight: 600,
}}>
<ShoppingCart size={20} />
Список
</button>
</div>
)}
{/* Notes list */}
{notes.map(note => {
const isActive = activeNote?.id === note.id
const doneCount = note.items?.filter(i => i.done).length || 0
const totalCount = note.items?.length || 0
return (
<motion.div
key={note.id}
layout
data-swipe-ignore
style={{ position: 'relative', borderRadius: 16, overflow: 'hidden' }}
>
{/* Delete reveal layer */}
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
padding: '0 20px', borderRadius: 16,
background: 'linear-gradient(90deg, transparent 30%, rgba(239,68,68,0.25))',
pointerEvents: 'none',
}}>
<Trash2 size={18} color="#f87171" />
</div>
<motion.button
drag="x"
dragConstraints={{ left: -80, right: 0 }}
dragElastic={0.1}
onDragEnd={(_, info) => {
if (info.offset.x < -60) setConfirmDelete(note)
}}
onClick={() => setActiveNote(note)}
style={{
padding: '14px 16px', borderRadius: 16, textAlign: 'left', width: '100%',
background: isActive ? `${note.color}15` : 'var(--surface-2)',
border: `1px solid ${isActive ? note.color + '30' : 'var(--border-subtle)'}`,
transition: 'background 0.2s ease, border-color 0.2s ease',
position: 'relative', zIndex: 1,
touchAction: 'pan-y',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
{note.type === 'shopping'
? <ShoppingCart size={14} color={note.color} />
: <FileText size={14} color={note.color} />
}
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{note.title}
</span>
</div>
{note.pinDate && (
<div style={{ fontSize: 11, color: note.color, fontWeight: 600, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
<CalendarIcon size={11} />
{new Date(note.pinDate).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })}
</div>
)}
{note.type === 'shopping' && totalCount > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{
flex: 1, height: 3, borderRadius: 2, background: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
}}>
<div style={{
width: `${totalCount > 0 ? (doneCount / totalCount) * 100 : 0}%`,
height: '100%', borderRadius: 2, background: note.color,
transition: 'width 0.3s ease',
}} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-secondary)', flexShrink: 0 }}>{doneCount}/{totalCount}</span>
</div>
)}
{note.type === 'note' && note.text && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.text}
</div>
)}
</motion.button>
</motion.div>
)
})}
{!loading && notes.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-secondary)' }}>
<FileText size={32} style={{ margin: '0 auto 12px', opacity: 0.3 }} />
<div style={{ fontSize: 14 }}>Нет заметок</div>
<div style={{ fontSize: 12, marginTop: 4 }}>Нажмите + чтобы создать</div>
</div>
)}
</div>
{/* Right: note editor */}
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
background: 'rgba(255,255,255,0.02)', borderRadius: 22,
border: '1px solid rgba(255,255,255,0.04)', overflow: 'hidden',
}}>
{!activeNote ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-secondary)' }}>
<div style={{ textAlign: 'center' }}>
<FileText size={40} style={{ margin: '0 auto 12px', opacity: 0.2 }} />
<div style={{ fontSize: 15 }}>Выберите заметку</div>
</div>
</div>
) : (
<>
{/* Header */}
<div style={{
padding: '18px 24px', borderBottom: '1px solid rgba(255,255,255,0.05)',
display: 'flex', flexDirection: 'column', gap: 10,
background: `${activeNote.color}08`,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<input
value={activeNote.title}
onChange={e => {
const newTitle = e.target.value
setActiveNote(prev => prev ? { ...prev, title: newTitle } : null)
}}
onBlur={() => updateNote(activeNote.id, { title: activeNote.title })}
style={{
background: 'transparent', border: 'none', outline: 'none',
fontSize: 18, fontWeight: 700, color: 'var(--text-primary)',
fontFamily: 'inherit', flex: 1, minWidth: 0,
}}
/>
<button
onClick={() => setConfirmDelete(activeNote)}
aria-label="Удалить заметку"
style={{
width: 44, height: 44, borderRadius: 12,
background: 'var(--data-danger-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--data-danger)', flexShrink: 0,
}}>
<Trash2 size={18} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CalendarIcon size={14} color={activeNote.color} />
<input
type="date"
value={activeNote.pinDate || ''}
onChange={e => {
const v = e.target.value || null
setActiveNote(prev => prev ? { ...prev, pinDate: v } : null)
updateNote(activeNote.id, { pinDate: v })
}}
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 10, padding: '6px 10px',
color: 'var(--text-primary)', fontSize: 13,
outline: 'none', fontFamily: 'inherit',
colorScheme: 'dark' as any,
}}
/>
{activeNote.pinDate && (
<button
onClick={() => {
setActiveNote(prev => prev ? { ...prev, pinDate: null } : null)
updateNote(activeNote.id, { pinDate: null })
}}
style={{
background: 'transparent', border: 'none',
color: 'var(--text-tertiary)', fontSize: 12,
padding: '4px 8px', cursor: 'pointer',
}}
>
Очистить
</button>
)}
</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px 24px' }}>
{activeNote.type === 'shopping' ? (
<>
{/* Add item */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={newItemText}
onChange={e => setNewItemText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addItem()}
placeholder="Добавить..."
style={{
flex: 1, padding: '12px 16px', borderRadius: 12,
background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)',
color: 'var(--text-primary)', fontSize: 15, outline: 'none', fontFamily: 'inherit',
boxSizing: 'border-box' as any,
}}
/>
<button onClick={addItem} style={{
width: 44, borderRadius: 12,
background: `${activeNote.color}20`, border: `1px solid ${activeNote.color}30`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: activeNote.color,
}}>
<Plus size={18} />
</button>
</div>
{/* Items */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(activeNote.items || []).filter(i => !i.done).map(item => (
<div key={item.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)',
}}>
<button onClick={() => toggleItem(item.id)} style={{
width: 22, height: 22, borderRadius: 6,
border: `2px solid ${activeNote.color}50`,
background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}} />
<span style={{ flex: 1, fontSize: 15, color: 'var(--text-primary)' }}>{item.text}</span>
<button onClick={() => removeItem(item.id)} style={{ color: 'var(--text-tertiary)', padding: 4, flexShrink: 0 }}>
<X size={14} />
</button>
</div>
))}
{/* Done items */}
{(activeNote.items || []).filter(i => i.done).length > 0 && (
<>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 12, marginBottom: 4 }}>
Выполнено
</div>
{(activeNote.items || []).filter(i => i.done).map(item => (
<div key={item.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px', borderRadius: 12,
background: 'rgba(255,255,255,0.01)',
opacity: 0.5,
}}>
<button onClick={() => toggleItem(item.id)} style={{
width: 22, height: 22, borderRadius: 6,
border: 'none', background: activeNote.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<Check size={14} color="#fff" />
</button>
<span style={{ flex: 1, fontSize: 15, color: 'var(--text-secondary)', textDecoration: 'line-through' }}>{item.text}</span>
<button onClick={() => removeItem(item.id)} style={{ color: 'var(--text-tertiary)', padding: 4, flexShrink: 0 }}>
<X size={14} />
</button>
</div>
))}
</>
)}
</div>
</>
) : (
/* Text note */
<textarea
value={activeNote.text || ''}
onChange={e => {
const newText = e.target.value
setActiveNote(prev => prev ? { ...prev, text: newText } : null)
}}
onBlur={() => updateNote(activeNote.id, { text: activeNote.text })}
placeholder="Начните писать..."
style={{
width: '100%', height: '100%', resize: 'none',
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--text-primary)', fontSize: 15, lineHeight: 1.7,
fontFamily: 'inherit', boxSizing: 'border-box' as any,
}}
/>
)}
</div>
</>
)}
</div>
{/* Delete confirm modal */}
{confirmDelete && (
<div
onClick={() => setConfirmDelete(null)}
data-swipe-ignore
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: '100%', maxWidth: 400,
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 24, padding: 28,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column', gap: 20,
}}
>
<div style={{
width: 56, height: 56, borderRadius: 18,
background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#f87171',
margin: '0 auto',
}}>
<Trash2 size={24} />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
{confirmDelete.type === 'shopping' ? 'Удалить список?' : 'Удалить заметку?'}
</div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
«{confirmDelete.title || 'Без названия'}» будет удалена безвозвратно
</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => setConfirmDelete(null)}
style={{
flex: 1, padding: '14px', borderRadius: 14,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--text-primary)',
fontSize: 14, fontWeight: 600,
fontFamily: 'inherit',
}}
>
Отмена
</button>
<button
onClick={() => {
deleteNote(confirmDelete.id)
setConfirmDelete(null)
}}
style={{
flex: 1, padding: '14px', borderRadius: 14,
background: 'linear-gradient(135deg, rgba(239,68,68,0.3), rgba(239,68,68,0.2))',
border: '1px solid rgba(239,68,68,0.35)',
color: '#fca5a5',
fontSize: 14, fontWeight: 600,
fontFamily: 'inherit',
}}
>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}