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

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>
)
}