All checks were successful
Deploy / deploy (push) Successful in 2m45s
- 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.
491 lines
21 KiB
TypeScript
491 lines
21 KiB
TypeScript
'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>
|
||
)
|
||
}
|