Files
smart-home-tablet/components/NotesTab.tsx
Cosmo bc01443f03
All checks were successful
Deploy / deploy (push) Successful in 2m54s
feat: Notes tab (notes + shopping lists), fix 7-day forecast layout, fix screensaver dismiss
2026-04-22 20:29:33 +00:00

343 lines
15 KiB
TypeScript
Raw 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 { 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>
)
}