Files
smart-home-tablet/components/NotesTab.tsx
Cosmo 8d32e7ebb0
All checks were successful
Deploy / deploy (push) Successful in 2m45s
feat: forecast swipe nav, note swipe-to-delete, night-shift tint
- WeatherDayModal now accepts the full forecast array and an onChange
  callback; supports horizontal drag (framer-motion) plus prev/next
  chevrons and a dot-indicator. Drag > 60px switches day; style uses
  semantic tokens (shadow-xl, surface-1).
- NotesTab list items wrap each note in a motion.button with drag=x,
  constrained to -80px. Below it a gradient+trash reveal layer. Drag
  past 60px opens the existing confirmDelete modal.
- HomePageInner adds a night-shift overlay (fixed, mixBlendMode multiply,
  rgba(255,120,40,0.12)) active 22:00-06:00, auto-checked each minute,
  fades in/out over 800ms. No user toggle yet — fully automatic.
2026-04-23 09:17:22 +00:00

491 lines
21 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 { 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
}
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 [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)} 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 (
<motion.div
key={note.id}
layout
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)} style={{
width: 32, height: 32, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#f87171', flexShrink: 0,
}}>
<Trash2 size={15} />
</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)}
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>
)
}