Sameer Singh commited on
Commit
d044ca3
·
0 Parent(s):

initial commit

Browse files
Files changed (9) hide show
  1. Dockerfile +11 -0
  2. README.md +54 -0
  3. __pycache__/app.cpython-313.pyc +0 -0
  4. app.py +144 -0
  5. notes.db +0 -0
  6. requirements.txt +2 -0
  7. static/app.js +161 -0
  8. static/index.html +60 -0
  9. static/style.css +251 -0
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ EXPOSE 7860
10
+
11
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quick Notes MVP
2
+
3
+ Quick Notes is a beginner-friendly portfolio project that demonstrates full CRUD with a FastAPI backend, SQLite database, and a vanilla HTML/CSS/JavaScript frontend.
4
+
5
+ ## Stack
6
+
7
+ - Backend: FastAPI
8
+ - Database: SQLite
9
+ - Frontend: HTML, CSS, Vanilla JavaScript
10
+ - Deployment target: Hugging Face Spaces (Docker)
11
+
12
+ ## Core Features
13
+
14
+ - Create note
15
+ - View all notes (latest first)
16
+ - Edit note
17
+ - Delete note
18
+ - Store and display timestamps (`created_at`, `updated_at`)
19
+
20
+ ## Nice Features
21
+
22
+ - Search notes by keyword
23
+ - Character counter
24
+ - Copy note button
25
+ - Dark mode toggle
26
+
27
+ ## Database Schema
28
+
29
+ `notes(id, content, created_at, updated_at)`
30
+
31
+ ## API Endpoints
32
+
33
+ - `POST /notes`
34
+ - `GET /notes`
35
+ - `PUT /notes/{id}`
36
+ - `DELETE /notes/{id}`
37
+
38
+ ## Run Locally
39
+
40
+ ```bash
41
+ pip install -r requirements.txt
42
+ uvicorn app:app --reload
43
+ ```
44
+
45
+ Open `http://127.0.0.1:8000`.
46
+
47
+ ## Docker
48
+
49
+ ```bash
50
+ docker build -t quick-notes .
51
+ docker run -p 7860:7860 quick-notes
52
+ ```
53
+
54
+ Open `http://127.0.0.1:7860`.
__pycache__/app.cpython-313.pyc ADDED
Binary file (6.23 kB). View file
 
app.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, HTTPException
6
+ from fastapi.responses import FileResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ from pydantic import BaseModel, Field
9
+
10
+ BASE_DIR = Path(__file__).resolve().parent
11
+ DB_PATH = BASE_DIR / "notes.db"
12
+
13
+ app = FastAPI(title="Quick Notes MVP")
14
+
15
+
16
+ class NotePayload(BaseModel):
17
+ content: str = Field(..., min_length=1)
18
+
19
+
20
+ def get_connection() -> sqlite3.Connection:
21
+ connection = sqlite3.connect(DB_PATH)
22
+ connection.row_factory = sqlite3.Row
23
+ return connection
24
+
25
+
26
+ def init_db() -> None:
27
+ connection = get_connection()
28
+ connection.execute(
29
+ """
30
+ CREATE TABLE IF NOT EXISTS notes (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ content TEXT NOT NULL,
33
+ created_at TEXT NOT NULL,
34
+ updated_at TEXT NOT NULL
35
+ )
36
+ """
37
+ )
38
+ connection.commit()
39
+ connection.close()
40
+
41
+
42
+ def row_to_dict(row: sqlite3.Row) -> dict:
43
+ return {
44
+ "id": row["id"],
45
+ "content": row["content"],
46
+ "created_at": row["created_at"],
47
+ "updated_at": row["updated_at"],
48
+ }
49
+
50
+
51
+ @app.on_event("startup")
52
+ def on_startup() -> None:
53
+ init_db()
54
+
55
+
56
+ app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
57
+
58
+
59
+ @app.get("/")
60
+ def home() -> FileResponse:
61
+ return FileResponse(BASE_DIR / "static" / "index.html")
62
+
63
+
64
+ @app.get("/notes")
65
+ def get_notes() -> list[dict]:
66
+ connection = get_connection()
67
+ rows = connection.execute(
68
+ (
69
+ "SELECT id, content, created_at, updated_at "
70
+ "FROM notes ORDER BY id DESC"
71
+ )
72
+ ).fetchall()
73
+ connection.close()
74
+ return [row_to_dict(row) for row in rows]
75
+
76
+
77
+ @app.post("/notes")
78
+ def create_note(payload: NotePayload) -> dict:
79
+ content = payload.content.strip()
80
+ if not content:
81
+ raise HTTPException(
82
+ status_code=400,
83
+ detail="Note content cannot be empty",
84
+ )
85
+
86
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
87
+
88
+ connection = get_connection()
89
+ cursor = connection.execute(
90
+ "INSERT INTO notes (content, created_at, updated_at) VALUES (?, ?, ?)",
91
+ (content, now, now),
92
+ )
93
+ connection.commit()
94
+ note_id = cursor.lastrowid
95
+ row = connection.execute(
96
+ "SELECT id, content, created_at, updated_at FROM notes WHERE id = ?",
97
+ (note_id,),
98
+ ).fetchone()
99
+ connection.close()
100
+
101
+ return row_to_dict(row)
102
+
103
+
104
+ @app.put("/notes/{note_id}")
105
+ def update_note(note_id: int, payload: NotePayload) -> dict:
106
+ content = payload.content.strip()
107
+ if not content:
108
+ raise HTTPException(
109
+ status_code=400,
110
+ detail="Note content cannot be empty",
111
+ )
112
+
113
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
114
+
115
+ connection = get_connection()
116
+ cursor = connection.execute(
117
+ "UPDATE notes SET content = ?, updated_at = ? WHERE id = ?",
118
+ (content, now, note_id),
119
+ )
120
+ connection.commit()
121
+
122
+ if cursor.rowcount == 0:
123
+ connection.close()
124
+ raise HTTPException(status_code=404, detail="Note not found")
125
+
126
+ row = connection.execute(
127
+ "SELECT id, content, created_at, updated_at FROM notes WHERE id = ?",
128
+ (note_id,),
129
+ ).fetchone()
130
+ connection.close()
131
+ return row_to_dict(row)
132
+
133
+
134
+ @app.delete("/notes/{note_id}")
135
+ def delete_note(note_id: int) -> dict:
136
+ connection = get_connection()
137
+ cursor = connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
138
+ connection.commit()
139
+ connection.close()
140
+
141
+ if cursor.rowcount == 0:
142
+ raise HTTPException(status_code=404, detail="Note not found")
143
+
144
+ return {"message": "Note deleted"}
notes.db ADDED
Binary file (12.3 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fastapi
2
+ uvicorn
static/app.js ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const noteInput = document.getElementById("note-input");
2
+ const charCount = document.getElementById("char-count");
3
+ const saveBtn = document.getElementById("save-btn");
4
+ const searchInput = document.getElementById("search-input");
5
+ const notesList = document.getElementById("notes-list");
6
+ const emptyState = document.getElementById("empty-state");
7
+ const noteTemplate = document.getElementById("note-template");
8
+ const themeToggle = document.getElementById("theme-toggle");
9
+
10
+ let notes = [];
11
+ let editingNoteId = null;
12
+
13
+ const THEME_KEY = "quick-notes-theme";
14
+
15
+ function formatDate(dateString) {
16
+ const date = new Date(dateString.replace(" ", "T"));
17
+ if (Number.isNaN(date.getTime())) {
18
+ return dateString;
19
+ }
20
+ return date.toLocaleString();
21
+ }
22
+
23
+ function updateCounter() {
24
+ charCount.textContent = `${noteInput.value.length} characters`;
25
+ }
26
+
27
+ function applySavedTheme() {
28
+ const saved = localStorage.getItem(THEME_KEY);
29
+ if (saved === "dark") {
30
+ document.body.classList.add("dark");
31
+ }
32
+ }
33
+
34
+ function toggleTheme() {
35
+ document.body.classList.toggle("dark");
36
+ const active = document.body.classList.contains("dark") ? "dark" : "light";
37
+ localStorage.setItem(THEME_KEY, active);
38
+ }
39
+
40
+ async function fetchNotes() {
41
+ const response = await fetch("/notes");
42
+ if (!response.ok) {
43
+ throw new Error("Failed to fetch notes");
44
+ }
45
+ notes = await response.json();
46
+ renderNotes();
47
+ }
48
+
49
+ async function saveNote() {
50
+ const content = noteInput.value.trim();
51
+ if (!content) {
52
+ alert("Please write a note before saving.");
53
+ return;
54
+ }
55
+
56
+ const method = editingNoteId ? "PUT" : "POST";
57
+ const endpoint = editingNoteId ? `/notes/${editingNoteId}` : "/notes";
58
+
59
+ const response = await fetch(endpoint, {
60
+ method,
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ content }),
63
+ });
64
+
65
+ if (!response.ok) {
66
+ alert("Could not save note.");
67
+ return;
68
+ }
69
+
70
+ editingNoteId = null;
71
+ noteInput.value = "";
72
+ updateCounter();
73
+ saveBtn.textContent = "Save Note";
74
+ await fetchNotes();
75
+ }
76
+
77
+ async function deleteNote(noteId) {
78
+ const confirmed = confirm("Delete this note?");
79
+ if (!confirmed) {
80
+ return;
81
+ }
82
+
83
+ const response = await fetch(`/notes/${noteId}`, { method: "DELETE" });
84
+ if (!response.ok) {
85
+ alert("Could not delete note.");
86
+ return;
87
+ }
88
+
89
+ if (editingNoteId === noteId) {
90
+ editingNoteId = null;
91
+ noteInput.value = "";
92
+ saveBtn.textContent = "Save Note";
93
+ updateCounter();
94
+ }
95
+
96
+ await fetchNotes();
97
+ }
98
+
99
+ async function copyNote(content) {
100
+ try {
101
+ await navigator.clipboard.writeText(content);
102
+ alert("Note copied.");
103
+ } catch (error) {
104
+ alert("Clipboard copy failed.");
105
+ }
106
+ }
107
+
108
+ function beginEdit(note) {
109
+ editingNoteId = note.id;
110
+ noteInput.value = note.content;
111
+ saveBtn.textContent = "Update Note";
112
+ updateCounter();
113
+ noteInput.focus();
114
+ }
115
+
116
+ function renderNotes() {
117
+ const query = searchInput.value.trim().toLowerCase();
118
+ const filteredNotes = notes.filter((note) =>
119
+ note.content.toLowerCase().includes(query)
120
+ );
121
+
122
+ notesList.innerHTML = "";
123
+
124
+ if (filteredNotes.length === 0) {
125
+ emptyState.style.display = "block";
126
+ emptyState.textContent = query
127
+ ? "No notes matched your search."
128
+ : "No notes yet. Save your first one.";
129
+ return;
130
+ }
131
+
132
+ emptyState.style.display = "none";
133
+
134
+ filteredNotes.forEach((note, index) => {
135
+ const fragment = noteTemplate.content.cloneNode(true);
136
+ const noteItem = fragment.querySelector(".note-item");
137
+
138
+ noteItem.style.animationDelay = `${index * 45}ms`;
139
+ fragment.querySelector(".note-content").textContent = note.content;
140
+ fragment.querySelector(".note-created").textContent = `Created: ${formatDate(note.created_at)}`;
141
+ fragment.querySelector(".note-updated").textContent = `Updated: ${formatDate(note.updated_at)}`;
142
+
143
+ fragment.querySelector(".edit-btn").addEventListener("click", () => beginEdit(note));
144
+ fragment.querySelector(".delete-btn").addEventListener("click", () => deleteNote(note.id));
145
+ fragment.querySelector(".copy-btn").addEventListener("click", () => copyNote(note.content));
146
+
147
+ notesList.appendChild(fragment);
148
+ });
149
+ }
150
+
151
+ noteInput.addEventListener("input", updateCounter);
152
+ saveBtn.addEventListener("click", saveNote);
153
+ searchInput.addEventListener("input", renderNotes);
154
+ themeToggle.addEventListener("click", toggleTheme);
155
+
156
+ applySavedTheme();
157
+ updateCounter();
158
+ fetchNotes().catch(() => {
159
+ emptyState.style.display = "block";
160
+ emptyState.textContent = "Could not load notes. Refresh to retry.";
161
+ });
static/index.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Quick Notes</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=DM+Serif+Display:ital@0;1&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="/static/style.css" />
11
+ </head>
12
+ <body>
13
+ <div class="ambient ambient-a"></div>
14
+ <div class="ambient ambient-b"></div>
15
+
16
+ <main class="shell">
17
+ <section class="header-card">
18
+ <p class="eyebrow">Quick Notes MVP</p>
19
+ <h1>Write fast. Find later.</h1>
20
+ <p class="subtitle">A lightweight notes board powered by FastAPI and SQLite.</p>
21
+ <button id="theme-toggle" class="theme-toggle" type="button">Toggle Dark Mode</button>
22
+ </section>
23
+
24
+ <section class="composer card">
25
+ <label for="note-input">Your note</label>
26
+ <textarea id="note-input" rows="5" placeholder="Capture thoughts, todos, and ideas..."></textarea>
27
+ <div class="composer-footer">
28
+ <span id="char-count">0 characters</span>
29
+ <button id="save-btn" type="button">Save Note</button>
30
+ </div>
31
+ </section>
32
+
33
+ <section class="notes-panel card">
34
+ <div class="notes-toolbar">
35
+ <h2>All Notes</h2>
36
+ <input id="search-input" type="search" placeholder="Search notes..." />
37
+ </div>
38
+ <p id="empty-state" class="empty-state">No notes yet. Save your first one.</p>
39
+ <div id="notes-list" class="notes-list"></div>
40
+ </section>
41
+ </main>
42
+
43
+ <template id="note-template">
44
+ <article class="note-item">
45
+ <p class="note-content"></p>
46
+ <div class="note-meta">
47
+ <span class="note-created"></span>
48
+ <span class="note-updated"></span>
49
+ </div>
50
+ <div class="note-actions">
51
+ <button class="copy-btn" type="button">Copy</button>
52
+ <button class="edit-btn" type="button">Edit</button>
53
+ <button class="delete-btn" type="button">Delete</button>
54
+ </div>
55
+ </article>
56
+ </template>
57
+
58
+ <script src="/static/app.js"></script>
59
+ </body>
60
+ </html>
static/style.css ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f7f4ef;
3
+ --bg-soft: #efe8dc;
4
+ --card: rgba(255, 255, 255, 0.78);
5
+ --text: #1f2328;
6
+ --muted: #61656b;
7
+ --accent: #d74722;
8
+ --accent-strong: #ac3418;
9
+ --border: rgba(31, 35, 40, 0.12);
10
+ --shadow: 0 18px 50px rgba(0, 0, 0, 0.14);
11
+ }
12
+
13
+ body.dark {
14
+ --bg: #14181d;
15
+ --bg-soft: #1d2530;
16
+ --card: rgba(31, 39, 50, 0.72);
17
+ --text: #eef2f7;
18
+ --muted: #adb8c5;
19
+ --accent: #ff8b44;
20
+ --accent-strong: #ff7331;
21
+ --border: rgba(255, 255, 255, 0.15);
22
+ --shadow: 0 18px 55px rgba(0, 0, 0, 0.4);
23
+ }
24
+
25
+ * {
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ margin: 0;
31
+ min-height: 100vh;
32
+ font-family: "Space Grotesk", sans-serif;
33
+ color: var(--text);
34
+ background:
35
+ radial-gradient(circle at 10% 10%, rgba(215, 71, 34, 0.16), transparent 45%),
36
+ radial-gradient(circle at 80% 95%, rgba(33, 126, 204, 0.18), transparent 40%),
37
+ linear-gradient(160deg, var(--bg), var(--bg-soft));
38
+ overflow-x: hidden;
39
+ }
40
+
41
+ .ambient {
42
+ position: fixed;
43
+ width: 350px;
44
+ height: 350px;
45
+ border-radius: 50%;
46
+ filter: blur(65px);
47
+ z-index: -1;
48
+ pointer-events: none;
49
+ }
50
+
51
+ .ambient-a {
52
+ top: -80px;
53
+ left: -60px;
54
+ background: rgba(239, 129, 69, 0.45);
55
+ }
56
+
57
+ .ambient-b {
58
+ right: -110px;
59
+ bottom: -90px;
60
+ background: rgba(53, 146, 226, 0.35);
61
+ }
62
+
63
+ .shell {
64
+ width: min(980px, 92vw);
65
+ margin: 2.5rem auto;
66
+ display: grid;
67
+ gap: 1.1rem;
68
+ animation: rise-in 480ms ease;
69
+ }
70
+
71
+ .header-card h1 {
72
+ font-family: "DM Serif Display", serif;
73
+ font-size: clamp(2rem, 3.2vw, 3rem);
74
+ margin: 0.2rem 0;
75
+ }
76
+
77
+ .eyebrow {
78
+ margin: 0;
79
+ letter-spacing: 0.1em;
80
+ text-transform: uppercase;
81
+ color: var(--muted);
82
+ font-size: 0.82rem;
83
+ }
84
+
85
+ .subtitle {
86
+ margin-top: 0.5rem;
87
+ color: var(--muted);
88
+ }
89
+
90
+ .card,
91
+ .header-card {
92
+ backdrop-filter: blur(8px);
93
+ background: var(--card);
94
+ border: 1px solid var(--border);
95
+ border-radius: 22px;
96
+ padding: 1rem 1.1rem;
97
+ box-shadow: var(--shadow);
98
+ }
99
+
100
+ .theme-toggle,
101
+ button {
102
+ border: 0;
103
+ border-radius: 12px;
104
+ background: var(--accent);
105
+ color: #fff;
106
+ font-weight: 700;
107
+ cursor: pointer;
108
+ transition: transform 180ms ease, background 180ms ease;
109
+ }
110
+
111
+ button:hover {
112
+ transform: translateY(-1px);
113
+ background: var(--accent-strong);
114
+ }
115
+
116
+ .theme-toggle {
117
+ padding: 0.55rem 0.85rem;
118
+ }
119
+
120
+ .composer {
121
+ display: grid;
122
+ gap: 0.7rem;
123
+ }
124
+
125
+ .composer textarea,
126
+ .notes-toolbar input {
127
+ width: 100%;
128
+ border: 1px solid var(--border);
129
+ border-radius: 14px;
130
+ padding: 0.8rem;
131
+ font: inherit;
132
+ color: var(--text);
133
+ background: rgba(255, 255, 255, 0.4);
134
+ }
135
+
136
+ body.dark .composer textarea,
137
+ body.dark .notes-toolbar input {
138
+ background: rgba(255, 255, 255, 0.06);
139
+ }
140
+
141
+ .composer-footer {
142
+ display: flex;
143
+ align-items: center;
144
+ justify-content: space-between;
145
+ }
146
+
147
+ #save-btn {
148
+ padding: 0.65rem 1rem;
149
+ }
150
+
151
+ #char-count {
152
+ color: var(--muted);
153
+ font-size: 0.93rem;
154
+ }
155
+
156
+ .notes-toolbar {
157
+ display: grid;
158
+ grid-template-columns: 1fr minmax(200px, 320px);
159
+ gap: 0.8rem;
160
+ align-items: center;
161
+ }
162
+
163
+ .notes-toolbar h2 {
164
+ margin: 0;
165
+ }
166
+
167
+ .notes-list {
168
+ margin-top: 0.9rem;
169
+ display: grid;
170
+ gap: 0.8rem;
171
+ }
172
+
173
+ .note-item {
174
+ border: 1px solid var(--border);
175
+ border-radius: 16px;
176
+ padding: 0.9rem;
177
+ background: rgba(255, 255, 255, 0.45);
178
+ opacity: 0;
179
+ transform: translateY(7px);
180
+ animation: note-in 260ms ease forwards;
181
+ }
182
+
183
+ body.dark .note-item {
184
+ background: rgba(255, 255, 255, 0.05);
185
+ }
186
+
187
+ .note-content {
188
+ margin: 0;
189
+ white-space: pre-wrap;
190
+ }
191
+
192
+ .note-meta {
193
+ display: flex;
194
+ gap: 1rem;
195
+ margin-top: 0.7rem;
196
+ color: var(--muted);
197
+ font-size: 0.83rem;
198
+ }
199
+
200
+ .note-actions {
201
+ margin-top: 0.75rem;
202
+ display: flex;
203
+ gap: 0.55rem;
204
+ }
205
+
206
+ .note-actions button {
207
+ padding: 0.45rem 0.7rem;
208
+ font-size: 0.86rem;
209
+ }
210
+
211
+ .empty-state {
212
+ color: var(--muted);
213
+ margin-top: 0.75rem;
214
+ }
215
+
216
+ @keyframes rise-in {
217
+ from {
218
+ opacity: 0;
219
+ transform: translateY(10px);
220
+ }
221
+ to {
222
+ opacity: 1;
223
+ transform: translateY(0);
224
+ }
225
+ }
226
+
227
+ @keyframes note-in {
228
+ to {
229
+ opacity: 1;
230
+ transform: translateY(0);
231
+ }
232
+ }
233
+
234
+ @media (max-width: 700px) {
235
+ .shell {
236
+ margin: 1rem auto 1.5rem;
237
+ }
238
+
239
+ .notes-toolbar {
240
+ grid-template-columns: 1fr;
241
+ }
242
+
243
+ .note-meta {
244
+ flex-direction: column;
245
+ gap: 0.25rem;
246
+ }
247
+
248
+ .note-actions {
249
+ flex-wrap: wrap;
250
+ }
251
+ }