feat: Notes tab (notes + shopping lists), fix 7-day forecast layout, fix screensaver dismiss
All checks were successful
Deploy / deploy (push) Successful in 2m54s

This commit is contained in:
Cosmo
2026-04-22 20:29:33 +00:00
parent a7611b46c4
commit bc01443f03
4 changed files with 445 additions and 11 deletions

76
app/api/notes/route.ts Normal file
View File

@@ -0,0 +1,76 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import * as fs from 'fs'
const NOTES_PATH = '/tmp/tablet-notes.json'
interface Note {
id: string
type: 'note' | 'shopping'
title: string
items?: { id: string; text: string; done: boolean }[]
text?: string
color: string
createdAt: string
updatedAt: string
}
function loadNotes(): Note[] {
try {
if (fs.existsSync(NOTES_PATH)) {
return JSON.parse(fs.readFileSync(NOTES_PATH, 'utf-8'))
}
} catch {}
return []
}
function saveNotes(notes: Note[]) {
fs.writeFileSync(NOTES_PATH, JSON.stringify(notes, null, 2))
}
export async function GET() {
return NextResponse.json({ notes: loadNotes() })
}
export async function POST(req: Request) {
const body = await req.json()
const notes = loadNotes()
const note: Note = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
type: body.type || 'note',
title: body.title || '',
items: body.type === 'shopping' ? [] : undefined,
text: body.type === 'note' ? '' : undefined,
color: body.color || '#6366f1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
notes.unshift(note)
saveNotes(notes)
return NextResponse.json({ note })
}
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 notes = loadNotes()
const idx = notes.findIndex(n => n.id === id)
if (idx === -1) return NextResponse.json({ error: 'not found' }, { status: 404 })
notes[idx] = { ...notes[idx], ...updates, updatedAt: new Date().toISOString() }
saveNotes(notes)
return NextResponse.json({ note: notes[idx] })
}
export async function DELETE(req: Request) {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
const notes = loadNotes()
const filtered = notes.filter(n => n.id !== id)
saveNotes(filtered)
return NextResponse.json({ success: true })
}

View File

@@ -8,9 +8,10 @@ import TopBar from '@/components/TopBar'
import RoomTabs from '@/components/RoomTabs' import RoomTabs from '@/components/RoomTabs'
import DeviceCard from '@/components/DeviceCard' import DeviceCard from '@/components/DeviceCard'
import CalendarTab from '@/components/CalendarTab' import CalendarTab from '@/components/CalendarTab'
import NotesTab from '@/components/NotesTab'
import WeatherAnimation from '@/components/WeatherAnimation' import WeatherAnimation from '@/components/WeatherAnimation'
type Tab = 'home' | 'devices' | 'calendar' | 'settings' type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
interface WeatherData { interface WeatherData {
temp: string temp: string
@@ -333,15 +334,23 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div> </div>
</div> </div>
{weather.forecast && weather.forecast.length > 0 && ( {weather.forecast && weather.forecast.length > 0 && (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 6, overflowX: 'auto', WebkitOverflowScrolling: 'touch' as any, scrollbarWidth: 'none' as any, msOverflowStyle: 'none' as any, paddingBottom: 2 }}>
{weather.forecast.map(day => { {weather.forecast.map((day, idx) => {
const d = new Date(day.date) const d = new Date(day.date)
const isToday = idx === 0
return ( return (
<div key={day.date} style={{ flex: 1, background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '10px 8px', textAlign: 'center', border: '1px solid rgba(255,255,255,0.04)' }}> <div key={day.date} style={{
<div style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4, fontWeight: 500 }}>{d.toLocaleDateString('ru-RU', { weekday: 'short' })}</div> minWidth: 58, background: isToday ? 'rgba(99,102,241,0.1)' : 'rgba(255,255,255,0.04)',
<div style={{ fontSize: 20, marginBottom: 4 }}>{getWeatherIcon(day.desc)}</div> borderRadius: 12, padding: '8px 6px', textAlign: 'center',
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div> border: isToday ? '1px solid rgba(129,140,248,0.2)' : '1px solid rgba(255,255,255,0.04)',
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{day.minTemp}°</div> flexShrink: 0,
}}>
<div style={{ fontSize: 9, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 3, fontWeight: 600 }}>
{isToday ? 'Сегодня' : d.toLocaleDateString('ru-RU', { weekday: 'short' })}
</div>
<div style={{ fontSize: 16, marginBottom: 3 }}>{getWeatherIcon(day.desc)}</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
<div style={{ fontSize: 10, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
</div> </div>
) )
})} })}
@@ -672,7 +681,7 @@ function HomePageInner() {
// Screensaver idle detection // Screensaver idle detection
const resetIdle = useCallback(() => { const resetIdle = useCallback(() => {
if (screensaverActive) { setScreensaverActive(false); return } if (screensaverActive) return // don't reset timer while screensaver is active
if (idleTimer.current) clearTimeout(idleTimer.current) if (idleTimer.current) clearTimeout(idleTimer.current)
idleTimer.current = setTimeout(() => setScreensaverActive(true), 2 * 60 * 1000) // 2 min idleTimer.current = setTimeout(() => setScreensaverActive(true), 2 * 60 * 1000) // 2 min
}, [screensaverActive]) }, [screensaverActive])
@@ -758,6 +767,12 @@ function HomePageInner() {
</motion.div> </motion.div>
)} )}
{tab === 'notes' && (
<motion.div key="notes" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<NotesTab />
</motion.div>
)}
{tab === 'settings' && ( {tab === 'settings' && (
<motion.div key="settings" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <motion.div key="settings" variants={tabVariants} initial="enter" animate="center" exit="exit" transition={{ duration: 0.2 }} style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<SettingsTab city={city} onCityChange={handleCityChange} onLogout={handleLogout} theme={theme} onThemeChange={setTheme} /> <SettingsTab city={city} onCityChange={handleCityChange} onLogout={handleLogout} theme={theme} onThemeChange={setTheme} />

342
components/NotesTab.tsx Normal file
View File

@@ -0,0 +1,342 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Plus, X, Trash2, ShoppingCart, FileText, Check, Circle } 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
createdAt: string
updatedAt: string
}
const NOTE_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#8b5cf6']
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 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)} style={{
width: 36, height: 36, borderRadius: 12,
background: showCreate ? 'rgba(255,255,255,0.06)' : 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))',
border: showCreate ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(129,140,248,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: showCreate ? 'var(--text-secondary)' : '#a5b4fc',
}}>
{showCreate ? <X size={16} /> : <Plus size={16} />}
</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 (
<button key={note.id} onClick={() => setActiveNote(note)} style={{
padding: '14px 16px', borderRadius: 16, textAlign: 'left', width: '100%',
background: isActive ? `${note.color}15` : 'rgba(255,255,255,0.025)',
border: `1px solid ${isActive ? note.color + '30' : 'rgba(255,255,255,0.05)'}`,
transition: 'all 0.2s ease',
}}>
<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.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>
)}
</button>
)
})}
{!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', alignItems: 'center', justifyContent: 'space-between',
background: `${activeNote.color}08`,
}}>
<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={() => { if (confirm('Удалить заметку?')) deleteNote(activeNote.id) }} style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#f87171',
}}>
<Trash2 size={15} />
</button>
</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>
</div>
)
}

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import { Home, Cpu, CalendarDays, Settings } from 'lucide-react' import { Home, Cpu, CalendarDays, StickyNote, Settings } from 'lucide-react'
type Tab = 'home' | 'devices' | 'calendar' | 'settings' type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
interface SidebarProps { interface SidebarProps {
active: Tab active: Tab
@@ -13,6 +13,7 @@ const navItems: { id: Tab; icon: any; label: string }[] = [
{ id: 'home', icon: Home, label: 'Главная' }, { id: 'home', icon: Home, label: 'Главная' },
{ id: 'devices', icon: Cpu, label: 'Устройства' }, { id: 'devices', icon: Cpu, label: 'Устройства' },
{ id: 'calendar', icon: CalendarDays, label: 'Календарь' }, { id: 'calendar', icon: CalendarDays, label: 'Календарь' },
{ id: 'notes', icon: StickyNote, label: 'Заметки' },
{ id: 'settings', icon: Settings, label: 'Настройки' }, { id: 'settings', icon: Settings, label: 'Настройки' },
] ]