snotes updates, actions, attachments, easy user space
This commit is contained in:
@@ -18,7 +18,7 @@ REMOTE_PASS=
|
|||||||
SECRET_KEY=change-me-in-production
|
SECRET_KEY=change-me-in-production
|
||||||
|
|
||||||
# Subnet(s) treated as "local" (no password), comma-separated
|
# Subnet(s) treated as "local" (no password), comma-separated
|
||||||
LAN_CIDRS=192.168.1.0/24
|
LAN_CIDRS=192.168.1.0/24,127.0.0.1/32,::1/128
|
||||||
|
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|||||||
BIN
assets/.DS_Store
vendored
Normal file
BIN
assets/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
assets/snotes-new.png
Normal file
BIN
assets/snotes-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
75
backend/actions.py
Normal file
75
backend/actions.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
ACTION_LINE_RE = re.compile(r"^/(?P<kw>[A-Za-z0-9_-]+)\s*:?\s*(?P<rest>.*)$")
|
||||||
|
|
||||||
|
_actions_cache: dict[str, dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_actions(reload: bool = False) -> dict[str, dict]:
|
||||||
|
global _actions_cache
|
||||||
|
if _actions_cache is not None and not reload:
|
||||||
|
return _actions_cache
|
||||||
|
|
||||||
|
actions: dict[str, dict] = {}
|
||||||
|
path = Path(settings.actions_file)
|
||||||
|
if path.is_file():
|
||||||
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||||
|
for item in data.get("actions", []):
|
||||||
|
name = item.get("name")
|
||||||
|
uri = item.get("uri")
|
||||||
|
if name and uri:
|
||||||
|
actions[name] = {"uri": uri, "link": item.get("link", uri)}
|
||||||
|
_actions_cache = actions
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
async def _perform(uri: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(uri)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("action_failed", uri=uri)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def process_actions(body: str) -> str:
|
||||||
|
actions = load_actions()
|
||||||
|
if not actions:
|
||||||
|
return body
|
||||||
|
|
||||||
|
out_lines: list[str] = []
|
||||||
|
for line in body.split("\n"):
|
||||||
|
m = ACTION_LINE_RE.match(line)
|
||||||
|
action = actions.get(m.group("kw")) if m else None
|
||||||
|
if action is None:
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
rest = m.group("rest").strip()
|
||||||
|
if not rest:
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
encoded = quote(rest)
|
||||||
|
uri = action["uri"].replace("#string1#", encoded)
|
||||||
|
link = action["link"].replace("#string1#", encoded)
|
||||||
|
if await _perform(uri):
|
||||||
|
out_lines.append(f"[{rest}]({link})")
|
||||||
|
else:
|
||||||
|
out_lines.append(line)
|
||||||
|
|
||||||
|
return "\n".join(out_lines)
|
||||||
@@ -16,6 +16,9 @@ class Settings(BaseSettings):
|
|||||||
# SQLite metadata + search index (local disk)
|
# SQLite metadata + search index (local disk)
|
||||||
database_path: str = "snotes.db"
|
database_path: str = "snotes.db"
|
||||||
|
|
||||||
|
# TOML file defining slash "actions" (see snotes.toml)
|
||||||
|
actions_file: str = "snotes.toml"
|
||||||
|
|
||||||
# Fixed list of usernames, comma-separated (seeded at startup)
|
# Fixed list of usernames, comma-separated (seeded at startup)
|
||||||
users: str = "schmeeve,ilya"
|
users: str = "schmeeve,ilya"
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ class Settings(BaseSettings):
|
|||||||
secret_key: str = "change-me-in-production"
|
secret_key: str = "change-me-in-production"
|
||||||
|
|
||||||
# Subnet(s) considered "local" and allowed in without a password
|
# Subnet(s) considered "local" and allowed in without a password
|
||||||
lan_cidrs: str = "192.168.1.0/24"
|
lan_cidrs: str = "192.168.1.0/24,127.0.0.1/32,::1/128"
|
||||||
|
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ async def init_db() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def seed_users() -> None:
|
async def seed_users() -> None:
|
||||||
|
names = list(dict.fromkeys([*settings.user_list, settings.common_space]))
|
||||||
async with aiosqlite.connect(settings.database_path) as db:
|
async with aiosqlite.connect(settings.database_path) as db:
|
||||||
for name in settings.user_list:
|
for name in names:
|
||||||
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
|
await db.execute("INSERT OR IGNORE INTO users (name) VALUES (?)", (name,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
@@ -49,6 +49,12 @@ def current_user(request: Request) -> str:
|
|||||||
return username or settings.user_list[0]
|
return username or settings.user_list[0]
|
||||||
|
|
||||||
|
|
||||||
|
def valid_username(username: str) -> str:
|
||||||
|
if username not in settings.user_list and username != settings.common_space:
|
||||||
|
raise HTTPException(status_code=404, detail="unknown user")
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SPA + static
|
# SPA + static
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -61,7 +67,7 @@ async def index() -> FileResponse:
|
|||||||
|
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
async def favicon() -> FileResponse:
|
async def favicon() -> FileResponse:
|
||||||
return FileResponse("static/snotes-main.png")
|
return FileResponse("static/snotes-new.png")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -114,6 +120,8 @@ async def login(body: LoginRequest) -> Response:
|
|||||||
@app.post("/api/notes")
|
@app.post("/api/notes")
|
||||||
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
|
async def create_note(body: NoteCreate, request: Request, _: None = Depends(require_access)):
|
||||||
username = current_user(request)
|
username = current_user(request)
|
||||||
|
if body.username:
|
||||||
|
username = valid_username(body.username)
|
||||||
if not body.body.strip():
|
if not body.body.strip():
|
||||||
raise HTTPException(status_code=400, detail="empty note")
|
raise HTTPException(status_code=400, detail="empty note")
|
||||||
try:
|
try:
|
||||||
@@ -148,7 +156,13 @@ async def get_note(note_id: str, _: None = Depends(require_access)):
|
|||||||
async def update_note(
|
async def update_note(
|
||||||
note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access)
|
note_id: str, body: NoteUpdate, request: Request, _: None = Depends(require_access)
|
||||||
):
|
):
|
||||||
note = await notes.update_note(note_id, body.body, ip=client_ip(request), ua=client_ua(request))
|
note = await notes.update_note(
|
||||||
|
note_id,
|
||||||
|
body.body,
|
||||||
|
ip=client_ip(request),
|
||||||
|
ua=client_ua(request),
|
||||||
|
username=valid_username(body.username) if body.username else None,
|
||||||
|
)
|
||||||
if note is None:
|
if note is None:
|
||||||
raise HTTPException(status_code=404, detail="note not found")
|
raise HTTPException(status_code=404, detail="note not found")
|
||||||
return note
|
return note
|
||||||
@@ -168,10 +182,45 @@ async def note_history(note_id: str, _: None = Depends(require_access)):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tags / search
|
# Attachments
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/attachments")
|
||||||
|
async def upload_attachment(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
username: str | None = Form(None),
|
||||||
|
_: None = Depends(require_access),
|
||||||
|
):
|
||||||
|
owner = current_user(request)
|
||||||
|
if username:
|
||||||
|
owner = valid_username(username)
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="empty file")
|
||||||
|
filename = await notes.store_attachment(owner, file.filename or "file", data)
|
||||||
|
return {
|
||||||
|
"url": f"/api/attachments/{owner}/{filename}",
|
||||||
|
"name": file.filename or filename,
|
||||||
|
"path": f"{owner}/attachments/{filename}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/attachments/{username}/{filename}")
|
||||||
|
async def get_attachment(username: str, filename: str, _: None = Depends(require_access)):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
full = Path(settings.data_dir) / username / "attachments" / filename
|
||||||
|
if not full.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
|
return FileResponse(full)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tags / search
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/api/tags")
|
@app.get("/api/tags")
|
||||||
async def tags(_: None = Depends(require_access)):
|
async def tags(_: None = Depends(require_access)):
|
||||||
return await notes.tag_counts()
|
return await notes.tag_counts()
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ class UserSelect(BaseModel):
|
|||||||
|
|
||||||
class NoteCreate(BaseModel):
|
class NoteCreate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NoteUpdate(BaseModel):
|
class NoteUpdate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import uuid
|
|||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
|
from backend import actions
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.database import get_user_id
|
from backend.database import get_user_id
|
||||||
|
|
||||||
@@ -75,6 +76,17 @@ def new_note_id() -> str:
|
|||||||
return uuid.uuid4().hex
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
async def store_attachment(username: str, original_name: str, data: bytes) -> str:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ext = re.sub(r"[^A-Za-z0-9.]", "", Path(original_name or "").suffix)[:16]
|
||||||
|
filename = new_note_id() + ext
|
||||||
|
full = Path(settings.data_dir) / username / "attachments" / filename
|
||||||
|
full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full.write_bytes(data)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Note CRUD (metadata + tags + FTS)
|
# Note CRUD (metadata + tags + FTS)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +98,7 @@ async def create_note(
|
|||||||
ip: str | None = None,
|
ip: str | None = None,
|
||||||
ua: str | None = None,
|
ua: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
body = await actions.process_actions(body)
|
||||||
title = extract_title(body)
|
title = extract_title(body)
|
||||||
tags = extract_tags(body)
|
tags = extract_tags(body)
|
||||||
note_id = new_note_id()
|
note_id = new_note_id()
|
||||||
@@ -123,7 +136,9 @@ async def update_note(
|
|||||||
body: str,
|
body: str,
|
||||||
ip: str | None = None,
|
ip: str | None = None,
|
||||||
ua: str | None = None,
|
ua: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
|
body = await actions.process_actions(body)
|
||||||
title = extract_title(body)
|
title = extract_title(body)
|
||||||
tags = extract_tags(body)
|
tags = extract_tags(body)
|
||||||
|
|
||||||
@@ -138,14 +153,31 @@ async def update_note(
|
|||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
new_username = username or row["username"]
|
||||||
|
user_id = row["user_id"]
|
||||||
|
rel = row["path"]
|
||||||
|
if new_username != row["username"]:
|
||||||
|
new_user_id = await get_user_id(db, new_username)
|
||||||
|
if new_user_id is None:
|
||||||
|
raise ValueError(f"unknown user: {new_username}")
|
||||||
|
await delete_note_file(row["path"])
|
||||||
|
rel = await write_note_file(new_username, note_id, body)
|
||||||
|
user_id = new_user_id
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE notes SET title = ?, path = ?, user_id = ?,"
|
||||||
|
" updated_at = datetime('now') WHERE id = ?",
|
||||||
|
(title, rel, user_id, note_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
await write_note_file(row["username"], note_id, body)
|
await write_note_file(row["username"], note_id, body)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
|
"UPDATE notes SET title = ?, updated_at = datetime('now') WHERE id = ?",
|
||||||
(title, note_id),
|
(title, note_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
await _set_tags(db, note_id, tags)
|
await _set_tags(db, note_id, tags)
|
||||||
await _index_note(db, note_id, row["user_id"], title, body)
|
await _index_note(db, note_id, user_id, title, body)
|
||||||
await _record_history(db, note_id, row["user_id"], "update", ip, ua)
|
await _record_history(db, note_id, user_id, "update", ip, ua)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await get_note(note_id)
|
return await get_note(note_id)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"structlog>=25.0.0",
|
"structlog>=25.0.0",
|
||||||
"itsdangerous>=2.2.0",
|
"itsdangerous>=2.2.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
9
snotes.toml
Normal file
9
snotes.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[[actions]]
|
||||||
|
name = "task"
|
||||||
|
uri = "http://tasky.loc/new?title=#string1#"
|
||||||
|
link = "http://tasky.loc/?title=#string1#"
|
||||||
|
|
||||||
|
[[actions]]
|
||||||
|
name = "foo"
|
||||||
|
url = "http://homepage.loc:3000/"
|
||||||
|
link = "http://homepage.loc:3000/"
|
||||||
187
static/app.css
187
static/app.css
@@ -1,14 +1,96 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f5f7fb;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--border: #e4e8f0;
|
|
||||||
--text: #1b2430;
|
|
||||||
--muted: #6b7688;
|
|
||||||
--primary: #3b82f6;
|
|
||||||
--primary-dark: #2563eb;
|
|
||||||
--danger: #ef4444;
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
||||||
|
--code-bg: #1e293b;
|
||||||
|
--code-fg: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark (default) ──────────────────────────────────── */
|
||||||
|
html, [data-theme="dark"] {
|
||||||
|
--bg: #1a1b1e; --surface: #26272b; --border: #3a3b40;
|
||||||
|
--text: #e4e4e7; --muted: #9ca3af;
|
||||||
|
--primary: #7dd3fc; --primary-bg: #0c4a6e; --primary-dark: #075985; --on-primary: #7dd3fc;
|
||||||
|
--danger: #f87171; --input-bg: #1a1b1e; --bg-hover: #2b2c31;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light ───────────────────────────────────────────── */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f7fb; --surface: #ffffff; --border: #e4e8f0;
|
||||||
|
--text: #1b2430; --muted: #6b7688;
|
||||||
|
--primary: #3b82f6; --primary-bg: #3b82f6; --primary-dark: #2563eb; --on-primary: #ffffff;
|
||||||
|
--danger: #ef4444; --input-bg: #ffffff; --bg-hover: #f3f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summer ──────────────────────────────────────────── */
|
||||||
|
[data-theme="summer"] {
|
||||||
|
--bg: #fff8f0; --surface: #fff0e0; --border: #ffd0a0;
|
||||||
|
--text: #4a3528; --muted: #9a8070;
|
||||||
|
--primary: #f08040; --primary-bg: #e06830; --primary-dark: #c05020; --on-primary: #fff8f0;
|
||||||
|
--danger: #c04030; --input-bg: #ffffff; --bg-hover: #ffe0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Max Summer ───────────────────────────────────────── */
|
||||||
|
[data-theme="max-summer"] {
|
||||||
|
--bg: #fffdf5; --surface: #fff9e8; --border: #ffe082;
|
||||||
|
--text: #212121; --muted: #757575;
|
||||||
|
--primary: #e53935; --primary-bg: #d32f2f; --primary-dark: #b71c1c; --on-primary: #fffdf5;
|
||||||
|
--danger: #c62828; --input-bg: #fffefa; --bg-hover: #fff3c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Autumn ──────────────────────────────────────────── */
|
||||||
|
[data-theme="autumn"] {
|
||||||
|
--bg: #f4f1ec; --surface: #eae5de; --border: #d4c8bc;
|
||||||
|
--text: #3d3228; --muted: #8a7a6a;
|
||||||
|
--primary: #d59b6a; --primary-bg: #d59b6a; --primary-dark: #c0834a; --on-primary: #f4f1ec;
|
||||||
|
--danger: #c04040; --input-bg: #ffffff; --bg-hover: #e0d8ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dracula ─────────────────────────────────────────── */
|
||||||
|
[data-theme="dracula"] {
|
||||||
|
--bg: #0c0c1e; --surface: #12122a; --border: #222;
|
||||||
|
--text: #eee; --muted: #aaa;
|
||||||
|
--primary: #7c6af7; --primary-bg: #4a3a9a; --primary-dark: #6a5aba; --on-primary: #eee;
|
||||||
|
--danger: #f87272; --input-bg: #1a1a3a; --bg-hover: #2a2a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nord ────────────────────────────────────────────── */
|
||||||
|
[data-theme="nord"] {
|
||||||
|
--bg: #2e3440; --surface: #3b4252; --border: #4c566a;
|
||||||
|
--text: #eceff4; --muted: #b0b9ca;
|
||||||
|
--primary: #88c0d0; --primary-bg: #5e81ac; --primary-dark: #81a1c1; --on-primary: #eceff4;
|
||||||
|
--danger: #bf616a; --input-bg: #3b4252; --bg-hover: #434c5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tokyo Night ─────────────────────────────────────── */
|
||||||
|
[data-theme="tokyo-night"] {
|
||||||
|
--bg: #1a1b26; --surface: #1f2335; --border: #2f3549;
|
||||||
|
--text: #c0caf5; --muted: #787c99;
|
||||||
|
--primary: #7aa2f7; --primary-bg: #3d59a1; --primary-dark: #5470b8; --on-primary: #a9b1d6;
|
||||||
|
--danger: #f7768e; --input-bg: #1f2335; --bg-hover: #292e42;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Catppuccin ──────────────────────────────────────── */
|
||||||
|
[data-theme="catppuccin"] {
|
||||||
|
--bg: #1e1e2e; --surface: #26263a; --border: #313150;
|
||||||
|
--text: #cdd6f4; --muted: #9399b2;
|
||||||
|
--primary: #cba6f7; --primary-bg: #7350a0; --primary-dark: #8b6bc0; --on-primary: #cdd6f4;
|
||||||
|
--danger: #f38ba8; --input-bg: #26263a; --bg-hover: #313150;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── One Dark ────────────────────────────────────────── */
|
||||||
|
[data-theme="one-dark"] {
|
||||||
|
--bg: #21252b; --surface: #282c34; --border: #333842;
|
||||||
|
--text: #dcdfe4; --muted: #9196a0;
|
||||||
|
--primary: #61afef; --primary-bg: #2b5780; --primary-dark: #3a6ea0; --on-primary: #61afef;
|
||||||
|
--danger: #e06c75; --input-bg: #282c34; --bg-hover: #333842;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sunset ──────────────────────────────────────────── */
|
||||||
|
[data-theme="sunset"] {
|
||||||
|
--bg: #1c1018; --surface: #251420; --border: #352030;
|
||||||
|
--text: #fce7f3; --muted: #b090a0;
|
||||||
|
--primary: #f472b6; --primary-bg: #831843; --primary-dark: #9d174d; --on-primary: #f9a8d4;
|
||||||
|
--danger: #f87171; --input-bg: #251420; --bg-hover: #352030;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -20,6 +102,7 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
@@ -46,25 +129,27 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
background: var(--bg);
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.search:focus { outline: 2px solid var(--primary); background: #fff; }
|
.search:focus { outline: 2px solid var(--primary); background: var(--input-bg); }
|
||||||
|
|
||||||
.topbar-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
.topbar-right { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
||||||
.user-select { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
|
.user-select { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); }
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.btn:hover { background: #f3f5f9; }
|
.btn:hover { background: var(--bg-hover); }
|
||||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.btn.primary { background: var(--primary-bg); border-color: var(--primary-bg); color: var(--on-primary); }
|
||||||
.btn.primary:hover { background: var(--primary-dark); }
|
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||||
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
||||||
.btn.ghost { background: transparent; border-color: transparent; }
|
.btn.ghost { background: transparent; border-color: transparent; }
|
||||||
|
|
||||||
@@ -97,7 +182,7 @@ body {
|
|||||||
|
|
||||||
.tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
|
.tag-cloud { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
background: #eef4ff;
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -105,14 +190,14 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tag-chip:hover { background: #dbe7ff; }
|
.tag-chip:hover { background: color-mix(in srgb, var(--primary) 22%, transparent); }
|
||||||
|
|
||||||
.editor-wrap { max-width: 900px; margin: 20px auto; padding: 0 20px; }
|
.editor-wrap { max-width: 900px; margin: 20px auto; padding: 0 20px; }
|
||||||
.editor-topbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
.editor-topbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||||
.spacer { flex: 1; }
|
.spacer { flex: 1; }
|
||||||
.toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; }
|
.toolbar { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; }
|
||||||
.tool-btn { padding: 6px 10px; border: none; background: transparent; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
.tool-btn { padding: 6px 10px; border: none; background: transparent; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text); }
|
||||||
.tool-btn:hover { background: #f3f5f9; }
|
.tool-btn:hover { background: var(--bg-hover); }
|
||||||
.sep { width: 1px; background: var(--border); margin: 4px 6px; }
|
.sep { width: 1px; background: var(--border); margin: 4px 6px; }
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
@@ -125,10 +210,11 @@ body {
|
|||||||
.editor .ProseMirror { min-height: 55vh; outline: none; }
|
.editor .ProseMirror { min-height: 55vh; outline: none; }
|
||||||
.editor h1, .editor h2, .editor h3 { margin: 0.5em 0 0.25em; }
|
.editor h1, .editor h2, .editor h3 { margin: 0.5em 0 0.25em; }
|
||||||
.editor p { margin: 0.4em 0; }
|
.editor p { margin: 0.4em 0; }
|
||||||
.editor pre { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 8px; overflow-x: auto; }
|
.editor pre { background: var(--code-bg); color: var(--code-fg); padding: 12px; border-radius: 8px; overflow-x: auto; }
|
||||||
.editor code { background: #f1f5f9; padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
|
.editor code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; }
|
||||||
.editor pre code { background: none; padding: 0; }
|
.editor pre code { background: none; padding: 0; }
|
||||||
.editor blockquote { border-left: 3px solid var(--primary); margin: 0.5em 0; padding-left: 12px; color: var(--muted); }
|
.editor blockquote { border-left: 3px solid var(--primary); margin: 0.5em 0; padding-left: 12px; color: var(--muted); }
|
||||||
|
.editor a { color: var(--primary); text-decoration: underline; cursor: pointer; }
|
||||||
|
|
||||||
.status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; }
|
.status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; }
|
||||||
|
|
||||||
@@ -146,7 +232,7 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.login-logo { width: 56px; height: 56px; border-radius: 12px; margin: 0 auto; object-fit: cover; }
|
.login-logo { width: 56px; height: 56px; border-radius: 12px; margin: 0 auto; object-fit: cover; }
|
||||||
.login-card input { padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; }
|
.login-card input { padding: 10px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); }
|
||||||
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
|
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
|
||||||
|
|
||||||
.overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
.overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
@@ -154,8 +240,65 @@ body {
|
|||||||
.history-list { list-style: none; padding: 0; margin: 0 0 16px; }
|
.history-list { list-style: none; padding: 0; margin: 0 0 16px; }
|
||||||
.history-list li { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
.history-list li { padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Theme selector ──────────────────────────────────── */
|
||||||
|
.theme-selector { position: relative; }
|
||||||
|
.theme-btn {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
font-size: 13px; font-family: inherit; cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { background: var(--bg-hover); }
|
||||||
|
.theme-label { color: var(--muted); }
|
||||||
|
.chevron { font-size: 9px; color: var(--muted); }
|
||||||
|
.theme-popover {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 0;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
padding: 10px; min-width: 200px; z-index: 60;
|
||||||
|
}
|
||||||
|
.theme-pop-header {
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.6px;
|
||||||
|
margin-bottom: 8px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||||
|
.theme-option {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 7px 8px; border: 1px solid transparent; border-radius: 6px;
|
||||||
|
background: var(--bg); cursor: pointer; font-family: inherit;
|
||||||
|
font-size: 13px; color: var(--text);
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.theme-option:hover { border-color: var(--border); }
|
||||||
|
.theme-option.active { border-color: var(--primary); }
|
||||||
|
.theme-swatch { display: flex; gap: 2px; flex-shrink: 0; }
|
||||||
|
.theme-dot { width: 10px; height: 10px; border-radius: 2px; }
|
||||||
|
.mode-row {
|
||||||
|
display: flex; gap: 4px; margin-bottom: 8px;
|
||||||
|
background: var(--bg); padding: 3px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1; padding: 5px 0; border: none; border-radius: 6px;
|
||||||
|
background: transparent; color: var(--muted);
|
||||||
|
font-family: inherit; font-size: 12px; cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.mode-btn.active { background: var(--primary-bg); color: var(--on-primary); font-weight: 600; }
|
||||||
|
.auto-panel { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.auto-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
|
||||||
|
.auto-panel select {
|
||||||
|
width: 100%; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
background: var(--input-bg); color: var(--text); font-family: inherit; font-size: 13px;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
.layout { grid-template-columns: 1fr; }
|
||||||
.topbar { flex-wrap: wrap; }
|
.topbar { flex-wrap: wrap; }
|
||||||
.search { max-width: none; order: 3; flex-basis: 100%; }
|
.search { max-width: none; order: 3; flex-basis: 100%; }
|
||||||
|
.theme-popover { right: -60px; }
|
||||||
}
|
}
|
||||||
|
|||||||
172
static/app.js
172
static/app.js
@@ -1,11 +1,109 @@
|
|||||||
import { Editor } from "https://esm.sh/@tiptap/core@2.11.5";
|
import { Editor } from "https://esm.sh/@tiptap/core@2.11.5";
|
||||||
import StarterKit from "https://esm.sh/@tiptap/starter-kit@2.11.5";
|
import StarterKit from "https://esm.sh/@tiptap/starter-kit@2.11.5";
|
||||||
|
import Link from "https://esm.sh/@tiptap/extension-link@2.11.5";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
||||||
marked.setOptions({ gfm: true, breaks: false });
|
marked.setOptions({ gfm: true, breaks: false });
|
||||||
|
|
||||||
const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home" };
|
const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home", space: null, userNames: null };
|
||||||
|
|
||||||
|
const THEMES = [
|
||||||
|
{ id: "dark", label: "Dark", dark: true, dots: ["#1a1b1e", "#26272b", "#7dd3fc"] },
|
||||||
|
{ id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6"] },
|
||||||
|
{ id: "summer", label: "Summer", dark: false, dots: ["#fff8f0", "#fff0e0", "#f08040"] },
|
||||||
|
{ id: "max-summer", label: "Max Summer", dark: false, dots: ["#fffdf5", "#fff9e8", "#e53935"] },
|
||||||
|
{ id: "autumn", label: "Autumn", dark: false, dots: ["#f4f1ec", "#eae5de", "#d59b6a"] },
|
||||||
|
{ id: "dracula", label: "Dracula", dark: true, dots: ["#0c0c1e", "#12122a", "#7c6af7"] },
|
||||||
|
{ id: "nord", label: "Nord", dark: true, dots: ["#2e3440", "#3b4252", "#88c0d0"] },
|
||||||
|
{ id: "tokyo-night", label: "Tokyo Night", dark: true, dots: ["#1a1b26", "#1f2335", "#7aa2f7"] },
|
||||||
|
{ id: "catppuccin", label: "Catppuccin", dark: true, dots: ["#1e1e2e", "#26263a", "#cba6f7"] },
|
||||||
|
{ id: "one-dark", label: "One Dark", dark: true, dots: ["#21252b", "#282c34", "#61afef"] },
|
||||||
|
{ id: "sunset", label: "Sunset", dark: true, dots: ["#1c1018", "#251420", "#f472b6"] },
|
||||||
|
];
|
||||||
|
const THEME_CONFIG_KEY = "snotes-theme-config";
|
||||||
|
const themeMQ = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
function findTheme(id) { return THEMES.find((t) => t.id === id); }
|
||||||
|
|
||||||
|
function loadThemeConfig() {
|
||||||
|
let cfg = {};
|
||||||
|
try { cfg = JSON.parse(localStorage.getItem(THEME_CONFIG_KEY)) || {}; } catch (e) {}
|
||||||
|
cfg.mode = cfg.mode === "manual" ? "manual" : "auto";
|
||||||
|
cfg.light = findTheme(cfg.light) ? cfg.light : "light";
|
||||||
|
cfg.dark = findTheme(cfg.dark) ? cfg.dark : "dark";
|
||||||
|
cfg.manual = findTheme(cfg.manual) ? cfg.manual : "dark";
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
let themeConfig = loadThemeConfig();
|
||||||
|
|
||||||
|
function saveThemeConfig() { try { localStorage.setItem(THEME_CONFIG_KEY, JSON.stringify(themeConfig)); } catch (e) {} }
|
||||||
|
|
||||||
|
function activeThemeId() {
|
||||||
|
return themeConfig.mode === "auto" ? (themeMQ.matches ? themeConfig.dark : themeConfig.light) : themeConfig.manual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
const id = activeThemeId();
|
||||||
|
document.documentElement.setAttribute("data-theme", id);
|
||||||
|
const t = findTheme(id);
|
||||||
|
const label = document.querySelector(".theme-label");
|
||||||
|
const swatch = document.querySelector(".theme-btn .theme-swatch");
|
||||||
|
if (label) label.textContent = t ? t.label : "";
|
||||||
|
if (swatch && t) swatch.innerHTML = t.dots.map((c) => `<span class="theme-dot" style="background:${c}"></span>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeSelector() {
|
||||||
|
const sel = el("div", { class: "theme-selector" });
|
||||||
|
const btn = el("button", { class: "theme-btn", title: "Change theme" }, [
|
||||||
|
el("span", { class: "theme-swatch" }),
|
||||||
|
el("span", { class: "theme-label" }),
|
||||||
|
el("span", { class: "chevron", html: "▾" }),
|
||||||
|
]);
|
||||||
|
const pop = el("div", { class: "theme-popover hidden" });
|
||||||
|
sel.appendChild(btn); sel.appendChild(pop);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
applyTheme();
|
||||||
|
pop.innerHTML = `
|
||||||
|
<div class="theme-pop-header">Themes</div>
|
||||||
|
<div class="mode-row">
|
||||||
|
<button type="button" class="mode-btn ${themeConfig.mode === "auto" ? "active" : ""}" data-mode="auto">Auto</button>
|
||||||
|
<button type="button" class="mode-btn ${themeConfig.mode === "manual" ? "active" : ""}" data-mode="manual">Manual</button>
|
||||||
|
</div>
|
||||||
|
<div class="auto-panel ${themeConfig.mode === "auto" ? "" : "hidden"}">
|
||||||
|
<label class="auto-label">Light theme</label>
|
||||||
|
<select data-role="light">${THEMES.filter((t) => !t.dark).map((t) => `<option value="${t.id}" ${t.id === themeConfig.light ? "selected" : ""}>${t.label}</option>`).join("")}</select>
|
||||||
|
<label class="auto-label">Dark theme</label>
|
||||||
|
<select data-role="dark">${THEMES.filter((t) => t.dark).map((t) => `<option value="${t.id}" ${t.id === themeConfig.dark ? "selected" : ""}>${t.label}</option>`).join("")}</select>
|
||||||
|
</div>
|
||||||
|
<div class="theme-grid ${themeConfig.mode === "manual" ? "" : "hidden"}">
|
||||||
|
${THEMES.map((t) => `<button type="button" class="theme-option ${themeConfig.mode === "manual" && t.id === themeConfig.manual ? "active" : ""}" data-theme="${t.id}">
|
||||||
|
<span class="theme-swatch">${t.dots.map((c) => `<span class="theme-dot" style="background:${c}"></span>`).join("")}</span>
|
||||||
|
<span>${t.label}</span>
|
||||||
|
</button>`).join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", (e) => { e.stopPropagation(); pop.classList.toggle("hidden"); });
|
||||||
|
pop.addEventListener("click", (e) => {
|
||||||
|
const modeBtn = e.target.closest(".mode-btn");
|
||||||
|
if (modeBtn) { themeConfig.mode = modeBtn.dataset.mode; saveThemeConfig(); render(); return; }
|
||||||
|
const opt = e.target.closest(".theme-option");
|
||||||
|
if (opt) { themeConfig.mode = "manual"; themeConfig.manual = opt.dataset.theme; saveThemeConfig(); render(); pop.classList.add("hidden"); }
|
||||||
|
});
|
||||||
|
pop.addEventListener("change", (e) => {
|
||||||
|
if (e.target.matches('select[data-role="light"]')) { themeConfig.light = e.target.value; saveThemeConfig(); render(); }
|
||||||
|
else if (e.target.matches('select[data-role="dark"]')) { themeConfig.dark = e.target.value; saveThemeConfig(); render(); }
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (e) => { if (!sel.contains(e.target)) pop.classList.add("hidden"); });
|
||||||
|
|
||||||
|
render();
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeMQ.addEventListener) themeMQ.addEventListener("change", () => { if (themeConfig.mode === "auto") applyTheme(); });
|
||||||
|
else if (themeMQ.addListener) themeMQ.addListener(() => { if (themeConfig.mode === "auto") applyTheme(); });
|
||||||
|
|
||||||
function el(tag, attrs = {}, children = []) {
|
function el(tag, attrs = {}, children = []) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
@@ -51,7 +149,7 @@ async function boot() {
|
|||||||
function showLogin() {
|
function showLogin() {
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
const form = el("form", { class: "login-card", onsubmit: async (e) => { e.preventDefault(); await doLogin(); } }, [
|
const form = el("form", { class: "login-card", onsubmit: async (e) => { e.preventDefault(); await doLogin(); } }, [
|
||||||
el("img", { src: "/static/snotes-main.png", class: "login-logo" }),
|
el("img", { src: "/static/snotes-new.png", class: "login-logo" }),
|
||||||
el("h1", { text: "snotes" }),
|
el("h1", { text: "snotes" }),
|
||||||
el("p", { class: "muted", text: "Remote access — enter password" }),
|
el("p", { class: "muted", text: "Remote access — enter password" }),
|
||||||
el("input", { type: "password", id: "pw", placeholder: "Password", autofocus: true }),
|
el("input", { type: "password", id: "pw", placeholder: "Password", autofocus: true }),
|
||||||
@@ -74,13 +172,14 @@ async function doLogin() {
|
|||||||
function header() {
|
function header() {
|
||||||
return el("header", { class: "topbar" }, [
|
return el("header", { class: "topbar" }, [
|
||||||
el("div", { class: "brand" }, [
|
el("div", { class: "brand" }, [
|
||||||
el("img", { src: "/static/snotes-main.png", class: "brand-logo" }),
|
el("img", { src: "/static/snotes-new.png", class: "brand-logo" }),
|
||||||
el("span", { text: "snotes" }),
|
el("span", { text: "snotes" }),
|
||||||
]),
|
]),
|
||||||
el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }),
|
el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }),
|
||||||
el("div", { class: "topbar-right" }, [
|
el("div", { class: "topbar-right" }, [
|
||||||
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
el("select", { id: "user-select", class: "user-select", onchange: onUserChange }),
|
||||||
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
|
el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }),
|
||||||
|
themeSelector(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -107,7 +206,8 @@ async function showHome() {
|
|||||||
async function populateUsers() {
|
async function populateUsers() {
|
||||||
const users = await api("GET", "/api/users");
|
const users = await api("GET", "/api/users");
|
||||||
const sel = document.getElementById("user-select");
|
const sel = document.getElementById("user-select");
|
||||||
const names = users.map((u) => u.name).concat(["common"]);
|
const names = users.map((u) => u.name);
|
||||||
|
state.userNames = names;
|
||||||
for (const n of names) {
|
for (const n of names) {
|
||||||
sel.appendChild(el("option", { value: n, text: n }));
|
sel.appendChild(el("option", { value: n, text: n }));
|
||||||
}
|
}
|
||||||
@@ -191,15 +291,16 @@ function debounce(fn, ms) {
|
|||||||
async function openNote(noteId) {
|
async function openNote(noteId) {
|
||||||
state.view = "editor";
|
state.view = "editor";
|
||||||
state.noteId = noteId;
|
state.noteId = noteId;
|
||||||
let body = "";
|
let body = "", owner = null;
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
const note = await api("GET", "/api/notes/" + noteId);
|
const note = await api("GET", "/api/notes/" + noteId);
|
||||||
body = note.body;
|
body = note.body;
|
||||||
|
owner = note.username;
|
||||||
}
|
}
|
||||||
renderEditor(body);
|
renderEditor(body, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor(body) {
|
function renderEditor(body, owner) {
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
state.editor?.destroy();
|
state.editor?.destroy();
|
||||||
|
|
||||||
@@ -208,6 +309,14 @@ function renderEditor(body) {
|
|||||||
const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null;
|
const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null;
|
||||||
const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null;
|
const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null;
|
||||||
|
|
||||||
|
const spaceSel = el("select", { id: "space-select", class: "user-select", onchange: (e) => { state.space = e.target.value; } });
|
||||||
|
for (const n of (state.userNames || [state.user])) {
|
||||||
|
spaceSel.appendChild(el("option", { value: n, text: n }));
|
||||||
|
}
|
||||||
|
const initialSpace = owner || state.space || state.user;
|
||||||
|
spaceSel.value = initialSpace;
|
||||||
|
state.space = initialSpace;
|
||||||
|
|
||||||
const toolbar = el("div", { class: "toolbar" }, [
|
const toolbar = el("div", { class: "toolbar" }, [
|
||||||
toolbarBtn("B", "bold"), toolbarBtn("I", "italic"),
|
toolbarBtn("B", "bold"), toolbarBtn("I", "italic"),
|
||||||
sep(), toolbarBtn("H1", "heading1"), toolbarBtn("H2", "heading2"), toolbarBtn("H3", "heading3"),
|
sep(), toolbarBtn("H1", "heading1"), toolbarBtn("H2", "heading2"), toolbarBtn("H3", "heading3"),
|
||||||
@@ -218,16 +327,22 @@ function renderEditor(body) {
|
|||||||
const editorArea = el("div", { class: "editor" });
|
const editorArea = el("div", { class: "editor" });
|
||||||
const status = el("div", { class: "status", text: "" });
|
const status = el("div", { class: "status", text: "" });
|
||||||
|
|
||||||
root.appendChild(el("div", { class: "editor-wrap" }, [
|
const fileInput = el("input", { type: "file", style: "display:none" });
|
||||||
el("div", { class: "editor-topbar" }, [back, el("div", { class: "spacer" }), hist, del, save]),
|
fileInput.addEventListener("change", () => uploadAttachment(fileInput));
|
||||||
|
const attach = el("button", { class: "btn", text: "Attach", onclick: () => fileInput.click() });
|
||||||
|
|
||||||
|
const wrap = el("div", { class: "editor-wrap" }, [
|
||||||
|
el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]),
|
||||||
toolbar,
|
toolbar,
|
||||||
editorArea,
|
editorArea,
|
||||||
status,
|
status,
|
||||||
]));
|
]);
|
||||||
|
wrap.appendChild(fileInput);
|
||||||
|
root.appendChild(wrap);
|
||||||
|
|
||||||
state.editor = new Editor({
|
state.editor = new Editor({
|
||||||
element: editorArea,
|
element: editorArea,
|
||||||
extensions: [StarterKit],
|
extensions: [StarterKit, Link.configure({ openOnClick: false })],
|
||||||
content: marked.parse(body),
|
content: marked.parse(body),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
});
|
});
|
||||||
@@ -262,14 +377,15 @@ async function saveNote() {
|
|||||||
const body = turndownService.turndown(html);
|
const body = turndownService.turndown(html);
|
||||||
const status = document.querySelector(".status");
|
const status = document.querySelector(".status");
|
||||||
try {
|
try {
|
||||||
|
let note;
|
||||||
if (state.noteId) {
|
if (state.noteId) {
|
||||||
const note = await api("PUT", "/api/notes/" + state.noteId, { body });
|
note = await api("PUT", "/api/notes/" + state.noteId, { body, username: state.space });
|
||||||
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
|
|
||||||
} else {
|
} else {
|
||||||
const note = await api("POST", "/api/notes", { body });
|
note = await api("POST", "/api/notes", { body, username: state.space });
|
||||||
state.noteId = note.id;
|
state.noteId = note.id;
|
||||||
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
|
|
||||||
}
|
}
|
||||||
|
state.editor.commands.setContent(marked.parse(note.body));
|
||||||
|
status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || "";
|
||||||
} catch (e) { status.textContent = "Error: " + e.message; }
|
} catch (e) { status.textContent = "Error: " + e.message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +395,32 @@ async function deleteNote() {
|
|||||||
showHome();
|
showHome();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadAttachment(input) {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("username", state.space);
|
||||||
|
const status = document.querySelector(".status");
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/attachments", { method: "POST", body: fd });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || resp.statusText);
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
state.editor.chain().focus().insertContent({
|
||||||
|
type: "text",
|
||||||
|
text: data.name,
|
||||||
|
marks: [{ type: "link", attrs: { href: data.url } }],
|
||||||
|
}).run();
|
||||||
|
if (status) status.textContent = "Attached " + data.name;
|
||||||
|
} catch (e) {
|
||||||
|
if (status) status.textContent = "Error: " + e.message;
|
||||||
|
}
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function showHistory() {
|
async function showHistory() {
|
||||||
const rows = await api("GET", "/api/notes/" + state.noteId + "/history");
|
const rows = await api("GET", "/api/notes/" + state.noteId + "/history");
|
||||||
const overlay = el("div", { class: "overlay" }, [
|
const overlay = el("div", { class: "overlay" }, [
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>snotes</title>
|
<title>snotes</title>
|
||||||
<link rel="icon" type="image/png" href="/static/snotes-main.png">
|
<script>
|
||||||
|
(function () {
|
||||||
|
var cfg = {};
|
||||||
|
try { cfg = JSON.parse(localStorage.getItem("snotes-theme-config")) || {}; } catch (e) {}
|
||||||
|
var id;
|
||||||
|
if (cfg.mode === "manual") {
|
||||||
|
id = cfg.manual || "dark";
|
||||||
|
} else {
|
||||||
|
var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
id = dark ? (cfg.dark || "dark") : (cfg.light || "light");
|
||||||
|
}
|
||||||
|
if (id) document.documentElement.setAttribute("data-theme", id);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="icon" type="image/png" href="/static/snotes-new.png">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
static/snotes-new.png
Normal file
BIN
static/snotes-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
@@ -15,6 +15,7 @@ async def client(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
|
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
|
||||||
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
|
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
|
||||||
|
monkeypatch.setattr(settings, "actions_file", str(tmp_path / "actions.toml"))
|
||||||
monkeypatch.setattr(settings, "users", "schmeeve,ilya")
|
monkeypatch.setattr(settings, "users", "schmeeve,ilya")
|
||||||
monkeypatch.setattr(settings, "remote_pass", "hunter2")
|
monkeypatch.setattr(settings, "remote_pass", "hunter2")
|
||||||
monkeypatch.setattr(settings, "secret_key", "test-secret")
|
monkeypatch.setattr(settings, "secret_key", "test-secret")
|
||||||
@@ -37,6 +38,7 @@ async def remote_client(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
|
monkeypatch.setattr(settings, "data_dir", str(tmp_path / "notes"))
|
||||||
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
|
monkeypatch.setattr(settings, "database_path", str(tmp_path / "test.db"))
|
||||||
|
monkeypatch.setattr(settings, "actions_file", str(tmp_path / "actions.toml"))
|
||||||
monkeypatch.setattr(settings, "users", "schmeeve")
|
monkeypatch.setattr(settings, "users", "schmeeve")
|
||||||
monkeypatch.setattr(settings, "remote_pass", "hunter2")
|
monkeypatch.setattr(settings, "remote_pass", "hunter2")
|
||||||
monkeypatch.setattr(settings, "secret_key", "test-secret")
|
monkeypatch.setattr(settings, "secret_key", "test-secret")
|
||||||
|
|||||||
89
tests/test_actions.py
Normal file
89
tests/test_actions.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from backend import actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_actions(tmp_path, monkeypatch):
|
||||||
|
f = tmp_path / "snotes.toml"
|
||||||
|
f.write_text(
|
||||||
|
'[[actions]]\nname = "task"\n'
|
||||||
|
'uri = "http://tasky.loc/new?title=#string1#"\n'
|
||||||
|
'link = "http://tasky.loc/?title=#string1#"\n'
|
||||||
|
'[[actions]]\nname = "other"\nuri = "http://x.loc/?q=#string1#"\n'
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(actions.settings, "actions_file", str(f))
|
||||||
|
got = actions.load_actions(reload=True)
|
||||||
|
assert set(got) == {"task", "other"}
|
||||||
|
assert got["task"]["uri"] == "http://tasky.loc/new?title=#string1#"
|
||||||
|
assert got["task"]["link"] == "http://tasky.loc/?title=#string1#"
|
||||||
|
# "other" has no link -> link defaults to uri
|
||||||
|
assert got["other"]["link"] == "http://x.loc/?q=#string1#"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_actions(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
actions,
|
||||||
|
"load_actions",
|
||||||
|
lambda: {
|
||||||
|
"task": {
|
||||||
|
"uri": "http://tasky.loc/new?title=#string1#",
|
||||||
|
"link": "http://tasky.loc/?title=#string1#",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
called = []
|
||||||
|
|
||||||
|
async def fake_perform(uri):
|
||||||
|
called.append(uri)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(actions, "_perform", fake_perform)
|
||||||
|
|
||||||
|
out = await actions.process_actions("before\n/task Go to the supermarket\nafter")
|
||||||
|
assert called == ["http://tasky.loc/new?title=Go%20to%20the%20supermarket"]
|
||||||
|
assert (
|
||||||
|
"[Go to the supermarket](http://tasky.loc/?title=Go%20to%20the%20supermarket)"
|
||||||
|
in out
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_actions_colon_separator(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
actions,
|
||||||
|
"load_actions",
|
||||||
|
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(actions, "_perform", lambda uri: _ok(uri))
|
||||||
|
|
||||||
|
out = await actions.process_actions("/task: shop milk")
|
||||||
|
assert "[shop milk](http://x/?t=shop%20milk)" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_actions_failure_leaves_line(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
actions,
|
||||||
|
"load_actions",
|
||||||
|
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fail(uri):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(actions, "_perform", fail)
|
||||||
|
out = await actions.process_actions("/task keep this line")
|
||||||
|
assert out == "/task keep this line"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process_actions_unknown_keyword(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
actions,
|
||||||
|
"load_actions",
|
||||||
|
lambda: {"task": {"uri": "http://x/?t=#string1#", "link": "http://x/?t=#string1#"}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(actions, "_perform", lambda uri: _ok(uri))
|
||||||
|
out = await actions.process_actions("/nope do nothing")
|
||||||
|
assert out == "/nope do nothing"
|
||||||
|
|
||||||
|
|
||||||
|
async def _ok(uri):
|
||||||
|
return True
|
||||||
@@ -34,6 +34,49 @@ async def test_users_seeded(client):
|
|||||||
names = [u["name"] for u in r.json()]
|
names = [u["name"] for u in r.json()]
|
||||||
assert "schmeeve" in names
|
assert "schmeeve" in names
|
||||||
assert "ilya" in names
|
assert "ilya" in names
|
||||||
|
assert "common" in names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_note_in_common_space(client):
|
||||||
|
r = await client.post("/api/notes", json={"body": "shared note", "username": "common"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["username"] == "common"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_move_note_between_spaces(client):
|
||||||
|
r = await client.post("/api/notes", json={"body": "start here"})
|
||||||
|
note_id = r.json()["id"]
|
||||||
|
assert r.json()["username"] == "schmeeve"
|
||||||
|
|
||||||
|
r2 = await client.put(f"/api/notes/{note_id}", json={"body": "moved", "username": "common"})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["username"] == "common"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_note_unknown_space(client):
|
||||||
|
r = await client.post("/api/notes", json={"body": "x", "username": "ghost"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_upload_and_serve_attachment(client):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/attachments",
|
||||||
|
files={"file": ("photo.png", b"fakeimage", "image/png")},
|
||||||
|
data={"username": "common"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["url"].startswith("/api/attachments/common/")
|
||||||
|
assert data["path"] == f"common/attachments/{data['url'].rsplit('/', 1)[-1]}"
|
||||||
|
|
||||||
|
r2 = await client.get(data["url"])
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.content == b"fakeimage"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachment_not_found(client):
|
||||||
|
r = await client.get("/api/attachments/common/does-not-exist.png")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_create_and_get_note(client):
|
async def test_create_and_get_note(client):
|
||||||
|
|||||||
11
uv.lock
generated
11
uv.lock
generated
@@ -388,6 +388,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.32"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -478,6 +487,7 @@ dependencies = [
|
|||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -508,6 +518,7 @@ requires-dist = [
|
|||||||
{ name = "pydantic-settings", specifier = ">=2.3.0" },
|
{ name = "pydantic-settings", specifier = ">=2.3.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||||
{ name = "structlog", specifier = ">=25.0.0" },
|
{ name = "structlog", specifier = ">=25.0.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.29.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user