Initial commit
This commit is contained in:
commit
71364b2fc0
49
Makefile
Normal file
49
Makefile
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Makefile for Adano Project
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
VENV_DIR := .venv
|
||||||
|
ACTIVATE := source $(VENV_DIR)/bin/activate
|
||||||
|
PYTHON := $(VENV_DIR)/bin/python
|
||||||
|
REQUIREMENTS := requirements.txt
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.PHONY: all
|
||||||
|
all: help
|
||||||
|
|
||||||
|
# Create virtual environment and install dependencies
|
||||||
|
.PHONY: install
|
||||||
|
install:
|
||||||
|
@echo "Setting up virtual environment..."
|
||||||
|
@test -d $(VENV_DIR) || python3 -m venv $(VENV_DIR)
|
||||||
|
@echo "Installing dependencies..."
|
||||||
|
$(PYTHON) -m pip install --upgrade pip
|
||||||
|
$(PYTHON) -m pip install -r $(REQUIREMENTS)
|
||||||
|
|
||||||
|
# Run the FastAPI app
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
@echo "Running the FastAPI server..."
|
||||||
|
$(PYTHON) -m uvicorn main:app
|
||||||
|
|
||||||
|
# Run tests (assuming you have tests set up)
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
# Add your test command here, e.g., pytest
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Clean up virtual environment and __pycache__
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning up..."
|
||||||
|
rm -rf $(VENV_DIR)
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
|
|
||||||
|
# Help message
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Makefile targets:"
|
||||||
|
@echo " install - Create virtual environment and install dependencies"
|
||||||
|
@echo " run - Run the FastAPI server"
|
||||||
|
@echo " test - Run tests"
|
||||||
|
@echo " clean - Remove virtual environment and cache files"
|
||||||
30
README.md
Normal file
30
README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Adano Notes & Tags Template
|
||||||
|
|
||||||
|
This repository is a template for a notes and tags application.
|
||||||
|
**Note:** This is not a standalone application for end-users. It serves as a clean, minimal base system.
|
||||||
|
|
||||||
|
The ADA project used this system as the foundation for its notes-taking application.
|
||||||
|
Due to its simplicity and clarity, I decided to give it its own dedicated repository.
|
||||||
|
|
||||||
|
## Functionality
|
||||||
|
|
||||||
|
- **URL Endpoints:**
|
||||||
|
- `GET /api/notes` — Retrieve all notes
|
||||||
|
- `POST /api/notes` — Create a new note
|
||||||
|
- `GET /api/notes/{id}` — Retrieve a specific note
|
||||||
|
- `PUT /api/notes/{id}` — Update a note
|
||||||
|
- `DELETE /api/notes/{id}` — Delete a note
|
||||||
|
- `GET /api/tags` — Retrieve all tags
|
||||||
|
- `POST /api/tags` — Create a new tag
|
||||||
|
- `GET /api/tags/{name}` — Retrieve a specific tag
|
||||||
|
- `PUT /api/tags/{name}` — Update a tag
|
||||||
|
- `DELETE /api/tags/{name}` — Delete a tag
|
||||||
|
- `POST /api/upload` — Upload files
|
||||||
|
- `GET /` — Serve the frontend application
|
||||||
|
- `GET /api/health` — Health check endpoint
|
||||||
|
|
||||||
|
*Note:* The above endpoints are indicative. The actual implementation may vary.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
*This is a template. Customize and extend as needed for your projects.*
|
||||||
442
frontend/index.html
Normal file
442
frontend/index.html
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
<!-- Written by retoor@molodetz.nl -->
|
||||||
|
|
||||||
|
<!-- This code represents a notes application called "Ada Notes" offering features such as composing, listing, and managing notes with tags and file attachments. The application uses HTML, CSS, and JavaScript with web components for custom note and compose elements. -->
|
||||||
|
|
||||||
|
<!-- The code utilizes an external library for markdown parsing: marked.js hosted via jsDelivr CDN. -->
|
||||||
|
|
||||||
|
<!-- MIT License -->
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Ada Notes</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 220px;
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--note-bg: #ffffff;
|
||||||
|
--note-radius: 8px;
|
||||||
|
--note-shadow: 0 1px 4px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; background: var(--bg); }
|
||||||
|
#app { display: flex; height: 100%; }
|
||||||
|
#sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: .75rem 0;
|
||||||
|
}
|
||||||
|
#sidebar h2 { margin: .5rem 1rem; font-size: 1rem; }
|
||||||
|
#sidebar ul { list-style: none; margin: 0; padding: 0; }
|
||||||
|
#sidebar li {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#sidebar li:hover,
|
||||||
|
#sidebar li.active { background: #f0f0f0; }
|
||||||
|
main { flex: 1; overflow-y: auto; padding: 1rem 1.5rem; }
|
||||||
|
#note-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
.note-card {
|
||||||
|
background: var(--note-bg);
|
||||||
|
border-radius: var(--note-radius);
|
||||||
|
box-shadow: var(--note-shadow);
|
||||||
|
padding: .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.note-card h3 { margin: 0; font-size: 1rem; }
|
||||||
|
.note-body { flex: 1; font-size: .9rem; }
|
||||||
|
.attachments { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.attachments img { max-width: 100%; border-radius: 4px; }
|
||||||
|
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: .5rem; }
|
||||||
|
.tag { background: #eee; padding: 2px 6px; border-radius: 4px; font-size: .75rem; }
|
||||||
|
note-compose { display: block; }
|
||||||
|
.compose-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--note-radius);
|
||||||
|
box-shadow: var(--note-shadow);
|
||||||
|
padding: .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.compose-wrapper textarea { resize: vertical; min-height: 60px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<ul id="tag-list"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<note-compose></note-compose>
|
||||||
|
<div id="note-grid"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<!--
|
||||||
|
<tag-input value="apple, banana, Cherry"></tag-input>
|
||||||
|
<script type="module" src="tag-input-component.html"></script>
|
||||||
|
|
||||||
|
A lightweight native Web Component that provides tag‑entry functionality.
|
||||||
|
▸ Prevents duplicates (case‑insensitive)
|
||||||
|
▸ Trims & lower‑cases everything
|
||||||
|
▸ Ignores tags < 3 characters
|
||||||
|
▸ Accepts comma or Enter to commit
|
||||||
|
▸ Keeps tags sorted
|
||||||
|
▸ Simple × button to delete
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
<tag-input value="apple, banana, Cherry"></tag-input>
|
||||||
|
<script type="module" src="tag-input-component.html"></script>
|
||||||
|
|
||||||
|
A lightweight native Web Component that provides tag-entry functionality.
|
||||||
|
▸ Prevents duplicates (case-insensitive)
|
||||||
|
▸ Trims & lower-cases everything
|
||||||
|
▸ Ignores tags < 3 characters
|
||||||
|
▸ Accepts comma or Enter to commit
|
||||||
|
▸ Keeps tags sorted
|
||||||
|
▸ Simple × button to delete
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template id="tag-input-template">
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
--bg: #f3f4f6;
|
||||||
|
--fg: #111827;
|
||||||
|
--tag-bg: #e5e7eb;
|
||||||
|
--tag-fg: #111827;
|
||||||
|
--tag-hover-bg: #d1d5db;
|
||||||
|
--tag-remove: #6b7280;
|
||||||
|
--tag-remove-hover: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
min-width: 12rem;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--tag-bg);
|
||||||
|
color: var(--tag-fg);
|
||||||
|
font-size: .875rem;
|
||||||
|
padding: .125rem .5rem;
|
||||||
|
border-radius: .375rem;
|
||||||
|
user-select: none;
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.tag:hover { background: var(--tag-hover-bg); }
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
all: unset;
|
||||||
|
margin-left: .25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--tag-remove);
|
||||||
|
}
|
||||||
|
.remove:hover { color: var(--tag-remove-hover); }
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1 0 6rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
font-size: .875rem;
|
||||||
|
min-width: 4rem;
|
||||||
|
padding: .125rem 0;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="wrapper">
|
||||||
|
<input type="text" placeholder="add tag…" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
class TagInput extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ["value"]; }
|
||||||
|
static formAssociated = true;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const tmpl = document.getElementById("tag-input-template").content.cloneNode(true);
|
||||||
|
this.attachShadow({ mode: "open" }).appendChild(tmpl);
|
||||||
|
this._internals = this.attachInternals();
|
||||||
|
this._input = this.shadowRoot.querySelector("input");
|
||||||
|
this._wrapper = this.shadowRoot.querySelector(".wrapper");
|
||||||
|
this._tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
// Parse initial value attribute or property
|
||||||
|
if (this.hasAttribute("value")) {
|
||||||
|
this._setTagsFromString(this.getAttribute("value"));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._upgradeProperty("value");
|
||||||
|
|
||||||
|
/* ────── UI interactions ────── */
|
||||||
|
this._input.addEventListener("keydown", e => {
|
||||||
|
|
||||||
|
if (e.key === "," || e.key === "Enter") {
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
this._commitInput();
|
||||||
|
// setTimeout(() =>{
|
||||||
|
this._input.value = ""
|
||||||
|
// },100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._input.addEventListener("blur", () => this._commitInput());
|
||||||
|
|
||||||
|
// Delegate click to delete buttons
|
||||||
|
this.shadowRoot.addEventListener("click", e => {
|
||||||
|
if (e.target.classList.contains("remove")) {
|
||||||
|
const tag = e.target.parentElement.dataset.tag;
|
||||||
|
this.removeTag(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clicking on wrapper focuses input
|
||||||
|
this._wrapper.addEventListener("click", () => this._input.focus());
|
||||||
|
|
||||||
|
this._renderTags();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldVal, newVal) {
|
||||||
|
if (name === "value" && oldVal !== newVal) {
|
||||||
|
this._setTagsFromString(newVal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────── Public API ────── */
|
||||||
|
get value() { return this._tags.join(", "); }
|
||||||
|
set value(v) { this.setAttribute("value", v); }
|
||||||
|
|
||||||
|
addTag(tag) {
|
||||||
|
const cleaned = this._cleanTag(tag);
|
||||||
|
if (!cleaned) return;
|
||||||
|
if (!this._tags.includes(cleaned)) {
|
||||||
|
this._tags.push(cleaned);
|
||||||
|
this._sort();
|
||||||
|
this._update();
|
||||||
|
this._input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTag(tag) {
|
||||||
|
const idx = this._tags.indexOf(tag.toLowerCase());
|
||||||
|
if (idx > -1) {
|
||||||
|
this._tags.splice(idx, 1);
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────── Internal helpers ────── */
|
||||||
|
_commitInput() {
|
||||||
|
const raw = this._input.value;
|
||||||
|
if (!raw) return;
|
||||||
|
raw.split(",").forEach(t => this.addTag(t));
|
||||||
|
this._input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanTag(tag) {
|
||||||
|
if (!tag) return "";
|
||||||
|
const t = tag.trim().toLowerCase();
|
||||||
|
return t.length >= 3 ? t : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_setTagsFromString(str) {
|
||||||
|
// Build a fresh, de-duplicated, cleaned array without triggering nested updates
|
||||||
|
const next = [];
|
||||||
|
if (str) {
|
||||||
|
str.split(/,\s*/).forEach(t => {
|
||||||
|
const cleaned = this._cleanTag(t);
|
||||||
|
if (cleaned && !next.includes(cleaned)) next.push(cleaned);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.sort();
|
||||||
|
this._tags = next;
|
||||||
|
this._update(false); // single update, no attribute reflection
|
||||||
|
}
|
||||||
|
|
||||||
|
_sort() { this._tags.sort(); }
|
||||||
|
|
||||||
|
_update(reflectAttr = true) {
|
||||||
|
this._renderTags();
|
||||||
|
const val = this.value;
|
||||||
|
if (reflectAttr) this.setAttribute("value", val);
|
||||||
|
this._internals.setFormValue(val);
|
||||||
|
|
||||||
|
// Emit external event
|
||||||
|
this.dispatchEvent(new CustomEvent("change", { detail: this._tags.slice() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_renderTags() {
|
||||||
|
// Remove any existing tag elements
|
||||||
|
const existing = Array.from(this._wrapper.querySelectorAll("span.tag"));
|
||||||
|
existing.forEach(el => el.remove());
|
||||||
|
|
||||||
|
// Build the new list
|
||||||
|
this._tags.forEach(tag => {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.className = "tag";
|
||||||
|
el.dataset.tag = tag;
|
||||||
|
el.textContent = tag;
|
||||||
|
|
||||||
|
const rm = document.createElement("button");
|
||||||
|
rm.className = "remove";
|
||||||
|
rm.textContent = "×";
|
||||||
|
el.appendChild(rm);
|
||||||
|
|
||||||
|
// Insert before the input inside .wrapper
|
||||||
|
this._wrapper.insertBefore(el, this._input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_upgradeProperty(prop) {
|
||||||
|
if (this.hasOwnProperty(prop)) {
|
||||||
|
const value = this[prop];
|
||||||
|
delete this[prop];
|
||||||
|
this[prop] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("tag-input", TagInput);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
/* NOTE CARD – dbl-click emits edit event */
|
||||||
|
class NoteCard extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.addEventListener('dblclick', () =>
|
||||||
|
document.dispatchEvent(new CustomEvent('edit-note', { detail: this.note }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
set data(n) { this.note = n; this.render(); }
|
||||||
|
render() {
|
||||||
|
if (!this.note) return;
|
||||||
|
const { title = '', body = '', attachments = [], tags = [] } = this.note;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="note-card">
|
||||||
|
${title ? `<h3>${title}</h3>` : ''}
|
||||||
|
<div class="note-body">${marked.parse(body)}</div>
|
||||||
|
${attachments.length ? `<div class="attachments">
|
||||||
|
${attachments.map(a => a.type === 'image'
|
||||||
|
? `<img src="${a.url}">`
|
||||||
|
: `<a href="${a.url}" target="_blank">${a.url}</a>`).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
${tags.length ? `<div class="tag-list">${tags.map(t => `<span class="tag">${t}</span>`).join('')}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('note-card', NoteCard);
|
||||||
|
|
||||||
|
/* COMPOSE – same form, now doubles as editor */
|
||||||
|
class NoteCompose extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.noteId = null; // null = new
|
||||||
|
this.innerHTML = `
|
||||||
|
<form class="compose-wrapper">
|
||||||
|
<input name="title" placeholder="Title" required>
|
||||||
|
<textarea name="body" placeholder="Take a note…" required></textarea>
|
||||||
|
<input name="files" type="file" multiple>
|
||||||
|
<tag-input name="tags" placeholder="Tags (comma-separated)"></tag-input>
|
||||||
|
<div style="display:flex;gap:.5rem;">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" id="cancel" style="display:none;">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
connectedCallback() {
|
||||||
|
this.form = this.querySelector('form')
|
||||||
|
this.form.addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
this.querySelector('#cancel').addEventListener('click', () => this.reset());
|
||||||
|
document.addEventListener('edit-note', e => this.load(e.detail));
|
||||||
|
}
|
||||||
|
q(s) { return this.querySelector(s); }
|
||||||
|
load(n) { /* pre-fill for edit */
|
||||||
|
this.noteId = n.id;
|
||||||
|
this.q('title').value = n.title;
|
||||||
|
this.q('body').value = n.body;
|
||||||
|
this.q('tags').value = (n.tags || []).join(', ');
|
||||||
|
this.q('#cancel').style.display = 'inline-block';
|
||||||
|
this.scrollIntoView({behavior:'smooth'});
|
||||||
|
}
|
||||||
|
async save() {
|
||||||
|
const fd = new FormData(this.form);
|
||||||
|
const note = {
|
||||||
|
title: fd.get('title').trim(),
|
||||||
|
body: fd.get('body').trim(),
|
||||||
|
tags: (fd.get('tags')||'').split(',').map(t=>t.trim()).filter(Boolean),
|
||||||
|
attachments: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/* uploads first */
|
||||||
|
for (const f of this.q('files').files) {
|
||||||
|
const up = new FormData(); up.append('file', f);
|
||||||
|
const r = await fetch(`${API_BASE}/upload`, {method:'POST', body:up});
|
||||||
|
if (r.ok) note.attachments.push(await r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = this.noteId ? 'PUT' : 'POST';
|
||||||
|
const url = this.noteId ? `${API_BASE}/notes/${this.noteId}` : `${API_BASE}/notes`;
|
||||||
|
const res = await fetch(url, {method,
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(note)});
|
||||||
|
if (res.ok) { this.reset(); document.dispatchEvent(new CustomEvent('notes-changed')); }
|
||||||
|
else alert('Save failed');
|
||||||
|
}
|
||||||
|
reset() { this.noteId=null; this.form.reset(); this.q('#cancel').style.display='none'; this.q('tags').value=''; }
|
||||||
|
q(sel){ return this.querySelector(sel.includes('#') ? sel : `[name=\"${sel}\"]`); }
|
||||||
|
}
|
||||||
|
customElements.define('note-compose', NoteCompose);
|
||||||
|
|
||||||
|
/* tiny loaders – unchanged */
|
||||||
|
async function loadNotes(tag){ const r=await fetch(tag?`${API_BASE}/notes?tag=${encodeURIComponent(tag)}`:`${API_BASE}/notes`); const ns=r.ok?await r.json():[]; const g=document.getElementById('note-grid'); g.innerHTML=''; ns.forEach(n=>{const c=document.createElement('note-card'); c.data=n; g.appendChild(c);} ); }
|
||||||
|
async function loadTags(){ const r=await fetch(`${API_BASE}/tags`); const ts=r.ok?await r.json():[]; const l=document.getElementById('tag-list'); l.innerHTML=''; ts.forEach(t=>{const li=document.createElement('li'); li.textContent=t.name||t; li.onclick=()=>{document.querySelectorAll('#tag-list li').forEach(x=>x.classList.toggle('active',x===li)); loadNotes(t.name||t);}; l.appendChild(li);} ); }
|
||||||
|
|
||||||
|
document.addEventListener('notes-changed', () => loadNotes());
|
||||||
|
loadNotes(); loadTags();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
177
main.py
Normal file
177
main.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Written by retoor@molodetz.nl
|
||||||
|
|
||||||
|
# This FastAPI application provides a backend API for a note-taking service called Ada Notes. The application allows for creating, listing, and managing notes, as well as handling file uploads and serving a frontend application for the service.
|
||||||
|
|
||||||
|
# The external libraries used in this code include FastAPI for the web framework, Uvicorn as the ASGI server, Dataset for database operations, and additional packages for multipart file handling.
|
||||||
|
|
||||||
|
# MIT License
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
import dataset
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
import os
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import pathlib
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
DB_URL = "sqlite:///" + str(BASE_DIR / "notes.db")
|
||||||
|
UPLOAD_DIR = pathlib.Path(".").joinpath("uploads")
|
||||||
|
FRONTEND_DIR = BASE_DIR / "frontend"
|
||||||
|
FRONTEND_INDEX = FRONTEND_DIR / "index.html"
|
||||||
|
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_semaphore = asyncio.Semaphore(10)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def db_session(transaction=False):
|
||||||
|
if transaction:
|
||||||
|
async with _semaphore:
|
||||||
|
db_ = dataset.connect(DB_URL)
|
||||||
|
db_.begin()
|
||||||
|
try:
|
||||||
|
yield db_
|
||||||
|
finally:
|
||||||
|
db_.commit()
|
||||||
|
db_.close()
|
||||||
|
else:
|
||||||
|
db_ = dataset.connect(DB_URL)
|
||||||
|
try:
|
||||||
|
yield db_
|
||||||
|
finally:
|
||||||
|
db_.close()
|
||||||
|
|
||||||
|
app = FastAPI(title="Ada Notes API", version="1.1.0")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=UPLOAD_DIR), name="static")
|
||||||
|
if pathlib.Path(FRONTEND_DIR).exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR), name="assets")
|
||||||
|
|
||||||
|
|
||||||
|
async def _serialize_note(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
note_id = row["id"]
|
||||||
|
|
||||||
|
async with db_session() as db:
|
||||||
|
atts = list(db['attachments'].find(note_id=note_id))
|
||||||
|
tags = [rt["tag"] for rt in db['note_tags'].find(note_id=note_id)]
|
||||||
|
return {
|
||||||
|
"id": note_id,
|
||||||
|
"title": row.get("title", ""),
|
||||||
|
"body": row.get("body", ""),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"updated_at": row.get("updated_at"),
|
||||||
|
"attachments": atts,
|
||||||
|
"tags": tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/notes")
|
||||||
|
async def list_notes(tag: Optional[str] = None):
|
||||||
|
async with db_session() as db:
|
||||||
|
if tag:
|
||||||
|
note_ids = [nt["note_id"] for nt in db['note_tags'].find(tag=tag)]
|
||||||
|
rows = [db['notes'].find_one(id=nid) for nid in note_ids]
|
||||||
|
else:
|
||||||
|
rows = list(db['notes'].all())
|
||||||
|
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
|
return [await _serialize_note(r) for r in rows if r]
|
||||||
|
|
||||||
|
@app.post("/api/notes")
|
||||||
|
async def create_note(payload: Dict[str, Any]):
|
||||||
|
return await _upsert_note(None, payload)
|
||||||
|
|
||||||
|
@app.put("/api/notes/{note_id}")
|
||||||
|
async def update_note(note_id: int, payload: Dict[str, Any]):
|
||||||
|
async with db_session() as db:
|
||||||
|
if not db['notes'].find_one(id=note_id):
|
||||||
|
raise HTTPException(404, "Note not found")
|
||||||
|
return await _upsert_note(note_id, payload)
|
||||||
|
|
||||||
|
async def _upsert_note(note_id: Optional[int], payload: Dict[str, Any]):
|
||||||
|
async with db_session() as db:
|
||||||
|
title = payload.get("title", "").strip()
|
||||||
|
body = payload.get("body", "")
|
||||||
|
tags: List[str] = payload.get("tags", [])
|
||||||
|
atts: List[Dict[str, str]] = payload.get("attachments", [])
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
if note_id is None:
|
||||||
|
note_id = db['notes'].insert({"title": title, "body": body, "created_at": now, "updated_at": now})
|
||||||
|
else:
|
||||||
|
db['notes'].update({"id": note_id, "title": title, "body": body, "updated_at": now}, ["id"])
|
||||||
|
db['attachments'].delete(note_id=note_id)
|
||||||
|
db['note_tags'].delete(note_id=note_id)
|
||||||
|
|
||||||
|
for t in tags:
|
||||||
|
t = t.strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if not db['tags'].find_one(name=t):
|
||||||
|
db['tags'].insert({"name": t})
|
||||||
|
db['note_tags'].insert({"note_id": note_id, "tag": t})
|
||||||
|
|
||||||
|
for att in atts:
|
||||||
|
db['attachments'].insert({"note_id": note_id, "url": att.get("url"), "type": att.get("type", "file")})
|
||||||
|
|
||||||
|
return await _serialize_note(db['notes'].find_one(id=note_id))
|
||||||
|
|
||||||
|
@app.get("/api/tags")
|
||||||
|
async def list_tags():
|
||||||
|
async with db_session() as db:
|
||||||
|
return [{"name": row["name"]} for row in db['tags'].all()]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/upload")
|
||||||
|
async def upload(file: UploadFile = File(...)):
|
||||||
|
filename = f"{uuid4().hex}_{file.filename}"
|
||||||
|
filepath = UPLOAD_DIR.joinpath(filename)
|
||||||
|
with filepath.open("wb") as buffer:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
buffer.write(chunk)
|
||||||
|
content_type = file.content_type or "application/octet-stream"
|
||||||
|
ftype = "image" if content_type.startswith("image/") else "file"
|
||||||
|
return {"url": f"/static/{filename}", "type": ftype}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=FileResponse, include_in_schema=False)
|
||||||
|
async def index():
|
||||||
|
if not pathlib.Path(FRONTEND_INDEX).exists():
|
||||||
|
raise HTTPException(status_code=404, detail="index.html not found. Place your frontend build in the 'frontend' directory.")
|
||||||
|
return FileResponse(FRONTEND_INDEX, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
python-multipart
|
||||||
|
fastapi
|
||||||
|
dataset
|
||||||
|
uvicorn
|
||||||
Loading…
Reference in New Issue
Block a user