Spaces:
Paused
Paused
Sameer Singh commited on
Commit ·
d044ca3
0
Parent(s):
initial commit
Browse files- Dockerfile +11 -0
- README.md +54 -0
- __pycache__/app.cpython-313.pyc +0 -0
- app.py +144 -0
- notes.db +0 -0
- requirements.txt +2 -0
- static/app.js +161 -0
- static/index.html +60 -0
- 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 |
+
}
|