Spaces:
Runtime error
Runtime error
Commit ·
1476658
1
Parent(s): 24c4edb
Upgrade DB, previews, and student UX flow
Browse files- .env.example +7 -1
- README.md +2 -2
- README_SPACES.md +8 -1
- app/blueprints/notes.py +179 -2
- app/templates/my_notes.html +23 -4
- app/templates/note_edit.html +67 -0
- app/templates/note_preview.html +75 -74
- app/templates/notes_list.html +33 -7
- app/templates/upload.html +26 -0
- app/utilities/s3.py +26 -0
- config.py +58 -1
- docker-entrypoint.sh +14 -0
- pyproject.toml +1 -0
- requirements.txt +1 -0
.env.example
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
SECRET_KEY=change_me
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
GOOGLE_CLIENT_ID=
|
| 5 |
GOOGLE_CLIENT_SECRET=
|
|
|
|
| 1 |
SECRET_KEY=change_me
|
| 2 |
+
# For Hugging Face Spaces persistence (recommended):
|
| 3 |
+
# DATABASE_URL=sqlite:////data/porahobebot.db
|
| 4 |
+
DATABASE_URL=
|
| 5 |
+
# Optional SQLite Cloud direct config (used if DATABASE_URL is empty)
|
| 6 |
+
SQLITECLOUD_HOST=
|
| 7 |
+
SQLITECLOUD_DB_NAME=porahobe
|
| 8 |
+
SQLITECLOUD_API_KEY=
|
| 9 |
|
| 10 |
GOOGLE_CLIENT_ID=
|
| 11 |
GOOGLE_CLIENT_SECRET=
|
README.md
CHANGED
|
@@ -82,8 +82,8 @@ Create a `.env` file in the project root directory. You can copy the structure f
|
|
| 82 |
# Core Security
|
| 83 |
SECRET_KEY=your_secure_random_key
|
| 84 |
|
| 85 |
-
# Database
|
| 86 |
-
DATABASE_URL=sqlite:///
|
| 87 |
|
| 88 |
# Authentication (Optional if not using OAuth)
|
| 89 |
GOOGLE_CLIENT_ID=your_google_client_id
|
|
|
|
| 82 |
# Core Security
|
| 83 |
SECRET_KEY=your_secure_random_key
|
| 84 |
|
| 85 |
+
# Database (for Hugging Face Spaces persistence)
|
| 86 |
+
DATABASE_URL=sqlite:////data/porahobebot.db
|
| 87 |
|
| 88 |
# Authentication (Optional if not using OAuth)
|
| 89 |
GOOGLE_CLIENT_ID=your_google_client_id
|
README_SPACES.md
CHANGED
|
@@ -11,7 +11,10 @@ Flask application for sharing and organizing notes.
|
|
| 11 |
## Environment variables (Space Settings -> Variables and secrets)
|
| 12 |
|
| 13 |
- `SECRET_KEY`
|
| 14 |
-
- `DATABASE_URL` (
|
|
|
|
|
|
|
|
|
|
| 15 |
- `GOOGLE_CLIENT_ID`
|
| 16 |
- `GOOGLE_CLIENT_SECRET`
|
| 17 |
- `DISCORD_CLIENT_ID`
|
|
@@ -28,6 +31,10 @@ Flask application for sharing and organizing notes.
|
|
| 28 |
## Notes
|
| 29 |
|
| 30 |
- The app listens on port `7860`.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
- DB migrations run at startup by default. Set `RUN_MIGRATIONS=0` to skip.
|
| 32 |
- If `migrations/` is missing, startup falls back to `db.create_all()` by default.
|
| 33 |
- Set `RUN_CREATE_ALL_IF_NO_MIGRATIONS=0` to disable that fallback.
|
|
|
|
| 11 |
## Environment variables (Space Settings -> Variables and secrets)
|
| 12 |
|
| 13 |
- `SECRET_KEY`
|
| 14 |
+
- `DATABASE_URL` (recommended: `sqlite:////data/porahobebot.db`)
|
| 15 |
+
- `SQLITECLOUD_HOST` (optional alternative to `DATABASE_URL`)
|
| 16 |
+
- `SQLITECLOUD_DB_NAME` (optional, default `porahobe`)
|
| 17 |
+
- `SQLITECLOUD_API_KEY` (optional alternative to `DATABASE_URL`)
|
| 18 |
- `GOOGLE_CLIENT_ID`
|
| 19 |
- `GOOGLE_CLIENT_SECRET`
|
| 20 |
- `DISCORD_CLIENT_ID`
|
|
|
|
| 31 |
## Notes
|
| 32 |
|
| 33 |
- The app listens on port `7860`.
|
| 34 |
+
- Enable Persistent Storage for your Space and keep the database on `/data`.
|
| 35 |
+
- If `DATABASE_URL` is unset, the app now auto-uses `/data/porahobebot.db` when `/data` exists.
|
| 36 |
+
- Startup includes a one-time copy from legacy paths (`app.db`, `instance/app.db`) into `/data/porahobebot.db` if the `/data` DB is missing.
|
| 37 |
+
- If you cannot use Space persistent storage, set `SQLITECLOUD_HOST`, `SQLITECLOUD_DB_NAME`, and `SQLITECLOUD_API_KEY`.
|
| 38 |
- DB migrations run at startup by default. Set `RUN_MIGRATIONS=0` to skip.
|
| 39 |
- If `migrations/` is missing, startup falls back to `db.create_all()` by default.
|
| 40 |
- Set `RUN_CREATE_ALL_IF_NO_MIGRATIONS=0` to disable that fallback.
|
app/blueprints/notes.py
CHANGED
|
@@ -10,6 +10,9 @@ from flask_login import current_user, login_required
|
|
| 10 |
from werkzeug.utils import secure_filename
|
| 11 |
from sqlalchemy import or_
|
| 12 |
import requests
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
from app.extensions import db
|
| 15 |
from app.models import Note, NoteType, Subject
|
|
@@ -17,6 +20,120 @@ from app.utilities.s3 import upload_to_s3, generate_presigned_url
|
|
| 17 |
|
| 18 |
notes_bp = Blueprint("notes", __name__)
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
@notes_bp.route("/upload", methods=["GET", "POST"])
|
| 22 |
@login_required
|
|
@@ -142,6 +259,7 @@ def list():
|
|
| 142 |
subject_id = request.args.get("subject")
|
| 143 |
note_type_id = request.args.get("note_type")
|
| 144 |
user_id = request.args.get("user")
|
|
|
|
| 145 |
|
| 146 |
query = Note.query
|
| 147 |
|
|
@@ -159,7 +277,14 @@ def list():
|
|
| 159 |
if user_id:
|
| 160 |
query = query.filter_by(user_id=user_id)
|
| 161 |
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
for note in notes:
|
| 165 |
if note.link and not note.original_link:
|
|
@@ -179,6 +304,7 @@ def list():
|
|
| 179 |
selected_subject=subject_id,
|
| 180 |
selected_note_type=note_type_id,
|
| 181 |
selected_user=user_id,
|
|
|
|
| 182 |
)
|
| 183 |
|
| 184 |
|
|
@@ -192,7 +318,8 @@ def preview(id):
|
|
| 192 |
else:
|
| 193 |
note.presigned_url = note.link
|
| 194 |
|
| 195 |
-
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
@notes_bp.route("/share/<int:id>")
|
|
@@ -203,6 +330,56 @@ def share(id):
|
|
| 203 |
return render_template("note_share.html", note=note, share_url=share_url)
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
@notes_bp.route("/my-notes")
|
| 207 |
@login_required
|
| 208 |
def my_notes():
|
|
|
|
| 10 |
from werkzeug.utils import secure_filename
|
| 11 |
from sqlalchemy import or_
|
| 12 |
import requests
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
from urllib.parse import parse_qs, quote_plus, urlparse
|
| 16 |
|
| 17 |
from app.extensions import db
|
| 18 |
from app.models import Note, NoteType, Subject
|
|
|
|
| 20 |
|
| 21 |
notes_bp = Blueprint("notes", __name__)
|
| 22 |
|
| 23 |
+
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"}
|
| 24 |
+
VIDEO_EXTENSIONS = {"mp4", "webm", "ogg", "mov", "m4v"}
|
| 25 |
+
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
|
| 26 |
+
TEXT_EXTENSIONS = {
|
| 27 |
+
"txt",
|
| 28 |
+
"md",
|
| 29 |
+
"csv",
|
| 30 |
+
"json",
|
| 31 |
+
"log",
|
| 32 |
+
"py",
|
| 33 |
+
"js",
|
| 34 |
+
"ts",
|
| 35 |
+
"html",
|
| 36 |
+
"css",
|
| 37 |
+
}
|
| 38 |
+
DOC_EXTENSIONS = {"doc", "docx", "ppt", "pptx", "xls", "xlsx"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _extract_extension(path_or_url):
|
| 42 |
+
parsed = urlparse(path_or_url or "")
|
| 43 |
+
filename = os.path.basename(parsed.path or path_or_url or "")
|
| 44 |
+
if "." not in filename:
|
| 45 |
+
return ""
|
| 46 |
+
return filename.rsplit(".", 1)[-1].lower()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _extract_youtube_embed_url(raw_url):
|
| 50 |
+
try:
|
| 51 |
+
parsed = urlparse(raw_url)
|
| 52 |
+
except Exception:
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
host = parsed.netloc.lower().replace("www.", "").replace("m.", "")
|
| 56 |
+
query = parse_qs(parsed.query)
|
| 57 |
+
path_parts = [p for p in parsed.path.split("/") if p]
|
| 58 |
+
video_id = None
|
| 59 |
+
playlist_id = (query.get("list") or [None])[0]
|
| 60 |
+
start_raw = (query.get("t") or query.get("start") or [None])[0]
|
| 61 |
+
|
| 62 |
+
if host == "youtu.be" and path_parts:
|
| 63 |
+
video_id = path_parts[0]
|
| 64 |
+
elif host.endswith("youtube.com"):
|
| 65 |
+
if parsed.path == "/watch":
|
| 66 |
+
video_id = (query.get("v") or [None])[0]
|
| 67 |
+
elif path_parts and path_parts[0] in {"shorts", "embed", "live", "v"}:
|
| 68 |
+
video_id = path_parts[1] if len(path_parts) > 1 else None
|
| 69 |
+
|
| 70 |
+
if not video_id and not playlist_id:
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
start_seconds = 0
|
| 74 |
+
if start_raw:
|
| 75 |
+
if str(start_raw).isdigit():
|
| 76 |
+
start_seconds = int(start_raw)
|
| 77 |
+
else:
|
| 78 |
+
match = re.match(
|
| 79 |
+
r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", str(start_raw).strip()
|
| 80 |
+
)
|
| 81 |
+
if match:
|
| 82 |
+
hours = int(match.group(1) or 0)
|
| 83 |
+
minutes = int(match.group(2) or 0)
|
| 84 |
+
seconds = int(match.group(3) or 0)
|
| 85 |
+
start_seconds = (hours * 3600) + (minutes * 60) + seconds
|
| 86 |
+
|
| 87 |
+
if video_id:
|
| 88 |
+
embed_url = f"https://www.youtube.com/embed/{video_id}"
|
| 89 |
+
params = []
|
| 90 |
+
if playlist_id:
|
| 91 |
+
params.append(f"list={playlist_id}")
|
| 92 |
+
if start_seconds > 0:
|
| 93 |
+
params.append(f"start={start_seconds}")
|
| 94 |
+
return f"{embed_url}?{'&'.join(params)}" if params else embed_url
|
| 95 |
+
|
| 96 |
+
return f"https://www.youtube.com/embed/videoseries?list={playlist_id}"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _build_preview_data(note):
|
| 100 |
+
if note.original_link:
|
| 101 |
+
external_url = note.link
|
| 102 |
+
youtube_embed = _extract_youtube_embed_url(external_url)
|
| 103 |
+
ext = _extract_extension(external_url)
|
| 104 |
+
|
| 105 |
+
if youtube_embed:
|
| 106 |
+
return {"kind": "youtube", "url": external_url, "embed_url": youtube_embed}
|
| 107 |
+
if ext == "pdf":
|
| 108 |
+
return {"kind": "iframe", "url": external_url}
|
| 109 |
+
return {"kind": "external", "url": external_url}
|
| 110 |
+
|
| 111 |
+
ext = _extract_extension(note.link)
|
| 112 |
+
file_url = note.presigned_url
|
| 113 |
+
filename = os.path.basename(note.link or "")
|
| 114 |
+
|
| 115 |
+
if ext in IMAGE_EXTENSIONS:
|
| 116 |
+
kind = "image"
|
| 117 |
+
elif ext == "pdf":
|
| 118 |
+
kind = "pdf"
|
| 119 |
+
elif ext in VIDEO_EXTENSIONS:
|
| 120 |
+
kind = "video"
|
| 121 |
+
elif ext in AUDIO_EXTENSIONS:
|
| 122 |
+
kind = "audio"
|
| 123 |
+
elif ext in TEXT_EXTENSIONS:
|
| 124 |
+
kind = "text"
|
| 125 |
+
elif ext in DOC_EXTENSIONS:
|
| 126 |
+
kind = "document"
|
| 127 |
+
else:
|
| 128 |
+
kind = "download"
|
| 129 |
+
|
| 130 |
+
data = {"kind": kind, "url": file_url, "filename": filename, "ext": ext}
|
| 131 |
+
if kind == "document":
|
| 132 |
+
data["viewer_url"] = (
|
| 133 |
+
f"https://docs.google.com/gview?embedded=1&url={quote_plus(file_url)}"
|
| 134 |
+
)
|
| 135 |
+
return data
|
| 136 |
+
|
| 137 |
|
| 138 |
@notes_bp.route("/upload", methods=["GET", "POST"])
|
| 139 |
@login_required
|
|
|
|
| 259 |
subject_id = request.args.get("subject")
|
| 260 |
note_type_id = request.args.get("note_type")
|
| 261 |
user_id = request.args.get("user")
|
| 262 |
+
sort = request.args.get("sort", "newest")
|
| 263 |
|
| 264 |
query = Note.query
|
| 265 |
|
|
|
|
| 277 |
if user_id:
|
| 278 |
query = query.filter_by(user_id=user_id)
|
| 279 |
|
| 280 |
+
if sort == "oldest":
|
| 281 |
+
query = query.order_by(Note.created_at.asc())
|
| 282 |
+
elif sort == "title":
|
| 283 |
+
query = query.order_by(Note.title.asc())
|
| 284 |
+
else:
|
| 285 |
+
query = query.order_by(Note.created_at.desc())
|
| 286 |
+
|
| 287 |
+
notes = query.all()
|
| 288 |
|
| 289 |
for note in notes:
|
| 290 |
if note.link and not note.original_link:
|
|
|
|
| 304 |
selected_subject=subject_id,
|
| 305 |
selected_note_type=note_type_id,
|
| 306 |
selected_user=user_id,
|
| 307 |
+
selected_sort=sort,
|
| 308 |
)
|
| 309 |
|
| 310 |
|
|
|
|
| 318 |
else:
|
| 319 |
note.presigned_url = note.link
|
| 320 |
|
| 321 |
+
preview_data = _build_preview_data(note)
|
| 322 |
+
return render_template("note_preview.html", note=note, preview=preview_data)
|
| 323 |
|
| 324 |
|
| 325 |
@notes_bp.route("/share/<int:id>")
|
|
|
|
| 330 |
return render_template("note_share.html", note=note, share_url=share_url)
|
| 331 |
|
| 332 |
|
| 333 |
+
@notes_bp.route("/edit/<int:id>", methods=["GET", "POST"])
|
| 334 |
+
@login_required
|
| 335 |
+
def edit(id):
|
| 336 |
+
note = Note.query.get_or_404(id)
|
| 337 |
+
|
| 338 |
+
if note.user_id != current_user.id:
|
| 339 |
+
return redirect(url_for("notes.my_notes"))
|
| 340 |
+
|
| 341 |
+
if request.method == "POST":
|
| 342 |
+
subject_id = request.form.get("subject")
|
| 343 |
+
note_type_name = request.form.get("note_type", "").strip()
|
| 344 |
+
title = request.form.get("title", "").strip()
|
| 345 |
+
description = request.form.get("description", "").strip()
|
| 346 |
+
external_link = request.form.get("external_link", "").strip()
|
| 347 |
+
|
| 348 |
+
if not subject_id or not note_type_name or not title:
|
| 349 |
+
return redirect(url_for("notes.edit", id=note.id))
|
| 350 |
+
|
| 351 |
+
subject = Subject.query.get(subject_id)
|
| 352 |
+
if not subject:
|
| 353 |
+
return redirect(url_for("notes.edit", id=note.id))
|
| 354 |
+
|
| 355 |
+
note_type_obj = NoteType.query.filter_by(name=note_type_name).first()
|
| 356 |
+
if not note_type_obj:
|
| 357 |
+
note_type_obj = NoteType(name=note_type_name)
|
| 358 |
+
db.session.add(note_type_obj)
|
| 359 |
+
db.session.flush()
|
| 360 |
+
|
| 361 |
+
note.title = title
|
| 362 |
+
note.description = description or None
|
| 363 |
+
note.subject_id = subject.id
|
| 364 |
+
note.note_type_id = note_type_obj.id
|
| 365 |
+
|
| 366 |
+
if note.original_link is not None and external_link:
|
| 367 |
+
note.link = external_link
|
| 368 |
+
note.original_link = external_link
|
| 369 |
+
|
| 370 |
+
db.session.commit()
|
| 371 |
+
return redirect(url_for("notes.preview", id=note.id))
|
| 372 |
+
|
| 373 |
+
subjects = Subject.query.order_by(Subject.name.asc()).all()
|
| 374 |
+
note_types = NoteType.query.order_by(NoteType.name.asc()).all()
|
| 375 |
+
return render_template(
|
| 376 |
+
"note_edit.html",
|
| 377 |
+
note=note,
|
| 378 |
+
subjects=subjects,
|
| 379 |
+
note_types=note_types,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
@notes_bp.route("/my-notes")
|
| 384 |
@login_required
|
| 385 |
def my_notes():
|
app/templates/my_notes.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
{% if notes %}
|
| 15 |
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 16 |
{% for note in notes %}
|
| 17 |
-
<div class="glass p-5 rounded-xl card-hover group">
|
| 18 |
<div class="flex items-start justify-between mb-3">
|
| 19 |
<div class="flex-1">
|
| 20 |
<h3 class="font-display text-lg font-bold mb-1 group-hover:text-blue-400 transition truncate">
|
|
@@ -40,8 +40,8 @@
|
|
| 40 |
</div>
|
| 41 |
|
| 42 |
<div class="flex gap-2">
|
| 43 |
-
<a href="{{ url_for('notes.
|
| 44 |
-
|
| 45 |
</a>
|
| 46 |
<a href="{{ url_for('notes.share', id=note.id) }}" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 font-mono text-[10px] text-center hover:bg-white/10 transition">
|
| 47 |
Share
|
|
@@ -64,4 +64,23 @@
|
|
| 64 |
</div>
|
| 65 |
{% endif %}
|
| 66 |
</div>
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
{% if notes %}
|
| 15 |
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 16 |
{% for note in notes %}
|
| 17 |
+
<div class="glass p-5 rounded-xl card-hover group cursor-pointer note-card" data-href="{{ url_for('notes.preview', id=note.id) }}" role="link" tabindex="0">
|
| 18 |
<div class="flex items-start justify-between mb-3">
|
| 19 |
<div class="flex-1">
|
| 20 |
<h3 class="font-display text-lg font-bold mb-1 group-hover:text-blue-400 transition truncate">
|
|
|
|
| 40 |
</div>
|
| 41 |
|
| 42 |
<div class="flex gap-2">
|
| 43 |
+
<a href="{{ url_for('notes.edit', id=note.id) }}" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 font-mono text-[10px] text-center hover:bg-white/10 transition">
|
| 44 |
+
Edit
|
| 45 |
</a>
|
| 46 |
<a href="{{ url_for('notes.share', id=note.id) }}" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 font-mono text-[10px] text-center hover:bg-white/10 transition">
|
| 47 |
Share
|
|
|
|
| 64 |
</div>
|
| 65 |
{% endif %}
|
| 66 |
</div>
|
| 67 |
+
<script>
|
| 68 |
+
document.addEventListener('DOMContentLoaded', function () {
|
| 69 |
+
const cards = document.querySelectorAll('.note-card[data-href]');
|
| 70 |
+
cards.forEach((card) => {
|
| 71 |
+
card.addEventListener('click', (event) => {
|
| 72 |
+
if (event.target.closest('a, button, input, select, textarea, form, label')) {
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
window.location.href = card.dataset.href;
|
| 76 |
+
});
|
| 77 |
+
card.addEventListener('keydown', (event) => {
|
| 78 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 79 |
+
event.preventDefault();
|
| 80 |
+
window.location.href = card.dataset.href;
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
});
|
| 84 |
+
});
|
| 85 |
+
</script>
|
| 86 |
+
{% endblock %}
|
app/templates/note_edit.html
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<div class="max-w-3xl mx-auto px-4 sm:px-6">
|
| 5 |
+
<div class="mb-8">
|
| 6 |
+
<h1 class="font-display text-4xl sm:text-5xl font-bold tracking-tight mb-2">
|
| 7 |
+
<span class="bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
| 8 |
+
Edit Note
|
| 9 |
+
</span>
|
| 10 |
+
</h1>
|
| 11 |
+
<p class="font-mono text-sm text-gray-400">Update title, tags, and details</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<form method="POST" class="glass p-6 rounded-2xl space-y-5">
|
| 15 |
+
<div>
|
| 16 |
+
<label class="block font-mono text-sm text-gray-300 mb-2">Title</label>
|
| 17 |
+
<input type="text" name="title" required value="{{ note.title }}"
|
| 18 |
+
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:border-blue-500 transition">
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 22 |
+
<div>
|
| 23 |
+
<label class="block font-mono text-sm text-gray-300 mb-2">Subject Tag</label>
|
| 24 |
+
<select name="subject" required
|
| 25 |
+
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:border-blue-500 transition">
|
| 26 |
+
{% for subject in subjects %}
|
| 27 |
+
<option value="{{ subject.id }}" {% if note.subject_id == subject.id %}selected{% endif %}>
|
| 28 |
+
{{ subject.name }}
|
| 29 |
+
</option>
|
| 30 |
+
{% endfor %}
|
| 31 |
+
</select>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div>
|
| 35 |
+
<label class="block font-mono text-sm text-gray-300 mb-2">Type Tag</label>
|
| 36 |
+
<input type="text" name="note_type" required value="{{ note.note_type.name }}"
|
| 37 |
+
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:border-blue-500 transition">
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div>
|
| 42 |
+
<label class="block font-mono text-sm text-gray-300 mb-2">Description</label>
|
| 43 |
+
<textarea name="description" rows="4"
|
| 44 |
+
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:border-blue-500 transition">{{ note.description or '' }}</textarea>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
{% if note.original_link %}
|
| 48 |
+
<div>
|
| 49 |
+
<label class="block font-mono text-sm text-gray-300 mb-2">External Link</label>
|
| 50 |
+
<input type="url" name="external_link" value="{{ note.original_link }}"
|
| 51 |
+
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono text-sm focus:outline-none focus:border-blue-500 transition">
|
| 52 |
+
</div>
|
| 53 |
+
{% endif %}
|
| 54 |
+
|
| 55 |
+
<div class="flex flex-wrap gap-3 pt-2">
|
| 56 |
+
<button type="submit"
|
| 57 |
+
class="btn-magnetic glass px-8 py-3 rounded-xl font-mono font-semibold border-2 border-blue-500/50 hover:border-blue-400 animate-glow">
|
| 58 |
+
Save Changes
|
| 59 |
+
</button>
|
| 60 |
+
<a href="{{ url_for('notes.preview', id=note.id) }}"
|
| 61 |
+
class="btn-magnetic glass px-8 py-3 rounded-xl font-mono font-semibold hover:bg-white/5">
|
| 62 |
+
Cancel
|
| 63 |
+
</a>
|
| 64 |
+
</div>
|
| 65 |
+
</form>
|
| 66 |
+
</div>
|
| 67 |
+
{% endblock %}
|
app/templates/note_preview.html
CHANGED
|
@@ -42,47 +42,87 @@
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
<!-- Preview Area -->
|
| 45 |
-
<div class="
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
<
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
</div>
|
| 58 |
-
{% else %}
|
| 59 |
-
{% set ext = note.link.split('.')[-1].lower() %}
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
|
| 84 |
<!-- Actions -->
|
| 85 |
<div class="flex gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
{% if note.presigned_url %}
|
| 87 |
{% if note.original_link %}
|
| 88 |
<a href="{{ note.presigned_url }}" target="_blank"
|
|
@@ -102,43 +142,4 @@
|
|
| 102 |
</a>
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
-
|
| 106 |
-
<script>
|
| 107 |
-
document.addEventListener('DOMContentLoaded', function () {
|
| 108 |
-
const linkPreview = document.getElementById('link-preview');
|
| 109 |
-
if (linkPreview) {
|
| 110 |
-
const url = linkPreview.getAttribute('data-url');
|
| 111 |
-
|
| 112 |
-
// Regex for Playlist
|
| 113 |
-
const playlistRegex = /[?&]list=([^#\&\?]+)/;
|
| 114 |
-
const playlistMatch = url.match(playlistRegex);
|
| 115 |
-
|
| 116 |
-
// Regex for Video
|
| 117 |
-
const videoRegex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
|
| 118 |
-
const videoMatch = url.match(videoRegex);
|
| 119 |
-
|
| 120 |
-
let embedUrl = null;
|
| 121 |
-
|
| 122 |
-
if (playlistMatch && playlistMatch[1]) {
|
| 123 |
-
embedUrl = `https://www.youtube.com/embed/videoseries?list=${playlistMatch[1]}`;
|
| 124 |
-
} else if (videoMatch && videoMatch[1]) {
|
| 125 |
-
embedUrl = `https://www.youtube.com/embed/${videoMatch[1]}`;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
if (embedUrl) {
|
| 129 |
-
const iframe = document.createElement('iframe');
|
| 130 |
-
iframe.setAttribute('src', embedUrl);
|
| 131 |
-
iframe.setAttribute('class', 'w-full aspect-video rounded-xl shadow-lg');
|
| 132 |
-
iframe.setAttribute('frameborder', '0');
|
| 133 |
-
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
|
| 134 |
-
iframe.setAttribute('allowfullscreen', 'true');
|
| 135 |
-
iframe.setAttribute('loading', 'eager');
|
| 136 |
-
|
| 137 |
-
linkPreview.innerHTML = '';
|
| 138 |
-
linkPreview.appendChild(iframe);
|
| 139 |
-
linkPreview.classList.remove('text-center', 'py-8');
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
-
});
|
| 143 |
-
</script>
|
| 144 |
{% endblock %}
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
<!-- Preview Area -->
|
| 45 |
+
<div class="grid grid-cols-1 xl:grid-cols-4 gap-4 mb-8">
|
| 46 |
+
<div class="glass p-4 sm:p-8 rounded-2xl xl:col-span-3" id="preview-container">
|
| 47 |
+
{% if preview.kind == 'youtube' %}
|
| 48 |
+
<div class="rounded-2xl overflow-hidden bg-black/40 border border-white/10">
|
| 49 |
+
<iframe src="{{ preview.embed_url }}" class="w-full aspect-video" frameborder="0"
|
| 50 |
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
| 51 |
+
allowfullscreen loading="lazy"></iframe>
|
| 52 |
+
</div>
|
| 53 |
+
{% elif preview.kind == 'image' %}
|
| 54 |
+
<img src="{{ preview.url }}" class="w-full rounded-xl object-contain max-h-[80vh]" alt="{{ note.title }}" loading="lazy">
|
| 55 |
+
{% elif preview.kind == 'pdf' %}
|
| 56 |
+
<iframe src="{{ preview.url }}" class="w-full h-[75vh] rounded-xl bg-white/5"></iframe>
|
| 57 |
+
{% elif preview.kind == 'video' %}
|
| 58 |
+
<video controls class="w-full rounded-xl bg-black/40">
|
| 59 |
+
<source src="{{ preview.url }}">
|
| 60 |
+
</video>
|
| 61 |
+
{% elif preview.kind == 'audio' %}
|
| 62 |
+
<div class="glass rounded-xl p-6">
|
| 63 |
+
<audio controls class="w-full">
|
| 64 |
+
<source src="{{ preview.url }}">
|
| 65 |
+
</audio>
|
| 66 |
+
</div>
|
| 67 |
+
{% elif preview.kind == 'text' %}
|
| 68 |
+
<iframe src="{{ preview.url }}" class="w-full h-[75vh] rounded-xl bg-white/5"></iframe>
|
| 69 |
+
{% elif preview.kind == 'document' %}
|
| 70 |
+
<iframe src="{{ preview.viewer_url }}" class="w-full h-[75vh] rounded-xl bg-white/5"></iframe>
|
| 71 |
+
{% elif preview.kind == 'iframe' %}
|
| 72 |
+
<iframe src="{{ preview.url }}" class="w-full h-[75vh] rounded-xl bg-white/5"></iframe>
|
| 73 |
+
{% else %}
|
| 74 |
+
<div class="text-center py-12">
|
| 75 |
+
<p class="font-mono text-gray-300 mb-3">Inline preview is not available for this resource.</p>
|
| 76 |
+
<a href="{{ preview.url }}" target="_blank"
|
| 77 |
+
class="inline-flex items-center gap-2 font-mono text-sm text-blue-400 hover:text-blue-300 transition break-all">
|
| 78 |
+
Open in new tab
|
| 79 |
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 80 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
| 81 |
+
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
| 82 |
+
</svg>
|
| 83 |
+
</a>
|
| 84 |
+
</div>
|
| 85 |
+
{% endif %}
|
| 86 |
</div>
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
<aside class="glass rounded-2xl p-5 space-y-4">
|
| 89 |
+
<h2 class="font-display text-xl">Preview Details</h2>
|
| 90 |
+
<div class="space-y-3 font-mono text-xs text-gray-300">
|
| 91 |
+
<div class="flex items-center justify-between gap-3">
|
| 92 |
+
<span class="text-gray-500">Mode</span>
|
| 93 |
+
<span class="uppercase tracking-wide">{{ preview.kind }}</span>
|
| 94 |
+
</div>
|
| 95 |
+
{% if preview.ext %}
|
| 96 |
+
<div class="flex items-center justify-between gap-3">
|
| 97 |
+
<span class="text-gray-500">File Type</span>
|
| 98 |
+
<span class="uppercase tracking-wide">{{ preview.ext }}</span>
|
| 99 |
+
</div>
|
| 100 |
+
{% endif %}
|
| 101 |
+
{% if preview.filename %}
|
| 102 |
+
<div class="pt-2">
|
| 103 |
+
<p class="text-gray-500 mb-1">Filename</p>
|
| 104 |
+
<p class="break-words text-gray-300">{{ preview.filename }}</p>
|
| 105 |
+
</div>
|
| 106 |
+
{% endif %}
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{% if preview.url %}
|
| 110 |
+
<a href="{{ preview.url }}" target="_blank"
|
| 111 |
+
class="w-full inline-flex justify-center items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10 font-mono text-xs hover:bg-white/10 transition">
|
| 112 |
+
Open Raw Link
|
| 113 |
+
</a>
|
| 114 |
+
{% endif %}
|
| 115 |
+
</aside>
|
| 116 |
</div>
|
| 117 |
|
| 118 |
<!-- Actions -->
|
| 119 |
<div class="flex gap-4">
|
| 120 |
+
{% if current_user.id == note.user_id %}
|
| 121 |
+
<a href="{{ url_for('notes.edit', id=note.id) }}"
|
| 122 |
+
class="btn-magnetic glass px-8 py-4 rounded-xl font-mono font-semibold hover:bg-white/5 text-center">
|
| 123 |
+
Edit
|
| 124 |
+
</a>
|
| 125 |
+
{% endif %}
|
| 126 |
{% if note.presigned_url %}
|
| 127 |
{% if note.original_link %}
|
| 128 |
<a href="{{ note.presigned_url }}" target="_blank"
|
|
|
|
| 142 |
</a>
|
| 143 |
</div>
|
| 144 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
{% endblock %}
|
app/templates/notes_list.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
|
| 14 |
<!-- Search & Filter -->
|
| 15 |
<form method="GET" class="glass p-4 rounded-xl mb-8">
|
| 16 |
-
<div class="grid grid-cols-1 md:grid-cols-
|
| 17 |
<input type="text" name="search" placeholder="Search..." value="{{ search or '' }}"
|
| 18 |
class="bg-white/5 border border-white/10 rounded-lg px-3 py-2 font-mono text-xs focus:outline-none focus:border-blue-500 transition">
|
| 19 |
|
|
@@ -36,6 +36,13 @@
|
|
| 36 |
</option>
|
| 37 |
{% endfor %}
|
| 38 |
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
<div class="flex gap-2">
|
| 41 |
<button type="submit" class="flex-1 bg-blue-500/20 border border-blue-500/50 rounded-lg px-3 py-2 font-mono text-xs hover:bg-blue-500/30 transition">
|
|
@@ -52,7 +59,7 @@
|
|
| 52 |
{% if notes %}
|
| 53 |
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 54 |
{% for note in notes %}
|
| 55 |
-
<div class="glass p-5 rounded-xl card-hover group">
|
| 56 |
<div class="flex items-start justify-between mb-3">
|
| 57 |
<div class="flex-1">
|
| 58 |
<h3 class="font-display text-lg font-bold mb-1 group-hover:text-blue-400 transition truncate">
|
|
@@ -65,6 +72,9 @@
|
|
| 65 |
<span class="px-2 py-0.5 bg-purple-500/20 border border-purple-500/50 rounded-md font-mono text-[10px]">
|
| 66 |
{{ note.note_type.name }}
|
| 67 |
</span>
|
|
|
|
|
|
|
|
|
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
</div>
|
|
@@ -81,10 +91,7 @@
|
|
| 81 |
</div>
|
| 82 |
|
| 83 |
<div class="flex gap-2">
|
| 84 |
-
<a href="{{ url_for('notes.
|
| 85 |
-
Preview
|
| 86 |
-
</a>
|
| 87 |
-
<a href="{{ url_for('notes.share', id=note.id) }}" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 font-mono text-[10px] text-center hover:bg-white/10 transition">
|
| 88 |
Share
|
| 89 |
</a>
|
| 90 |
</div>
|
|
@@ -97,4 +104,23 @@
|
|
| 97 |
</div>
|
| 98 |
{% endif %}
|
| 99 |
</div>
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
<!-- Search & Filter -->
|
| 15 |
<form method="GET" class="glass p-4 rounded-xl mb-8">
|
| 16 |
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-3">
|
| 17 |
<input type="text" name="search" placeholder="Search..." value="{{ search or '' }}"
|
| 18 |
class="bg-white/5 border border-white/10 rounded-lg px-3 py-2 font-mono text-xs focus:outline-none focus:border-blue-500 transition">
|
| 19 |
|
|
|
|
| 36 |
</option>
|
| 37 |
{% endfor %}
|
| 38 |
</select>
|
| 39 |
+
|
| 40 |
+
<select name="sort"
|
| 41 |
+
class="bg-white/5 border border-white/10 rounded-lg px-3 py-2 font-mono text-xs focus:outline-none focus:border-blue-500 transition">
|
| 42 |
+
<option value="newest" {% if selected_sort == 'newest' %}selected{% endif %}>Newest first</option>
|
| 43 |
+
<option value="oldest" {% if selected_sort == 'oldest' %}selected{% endif %}>Oldest first</option>
|
| 44 |
+
<option value="title" {% if selected_sort == 'title' %}selected{% endif %}>Title A-Z</option>
|
| 45 |
+
</select>
|
| 46 |
|
| 47 |
<div class="flex gap-2">
|
| 48 |
<button type="submit" class="flex-1 bg-blue-500/20 border border-blue-500/50 rounded-lg px-3 py-2 font-mono text-xs hover:bg-blue-500/30 transition">
|
|
|
|
| 59 |
{% if notes %}
|
| 60 |
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 61 |
{% for note in notes %}
|
| 62 |
+
<div class="glass p-5 rounded-xl card-hover group cursor-pointer note-card" data-href="{{ url_for('notes.preview', id=note.id) }}" role="link" tabindex="0">
|
| 63 |
<div class="flex items-start justify-between mb-3">
|
| 64 |
<div class="flex-1">
|
| 65 |
<h3 class="font-display text-lg font-bold mb-1 group-hover:text-blue-400 transition truncate">
|
|
|
|
| 72 |
<span class="px-2 py-0.5 bg-purple-500/20 border border-purple-500/50 rounded-md font-mono text-[10px]">
|
| 73 |
{{ note.note_type.name }}
|
| 74 |
</span>
|
| 75 |
+
<span class="px-2 py-0.5 bg-white/5 border border-white/10 rounded-md font-mono text-[10px] text-gray-400">
|
| 76 |
+
{% if note.original_link %}Link{% else %}File{% endif %}
|
| 77 |
+
</span>
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
</div>
|
|
|
|
| 91 |
</div>
|
| 92 |
|
| 93 |
<div class="flex gap-2">
|
| 94 |
+
<a href="{{ url_for('notes.share', id=note.id) }}" class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 font-mono text-[10px] text-center hover:bg-white/10 transition interactive-link">
|
|
|
|
|
|
|
|
|
|
| 95 |
Share
|
| 96 |
</a>
|
| 97 |
</div>
|
|
|
|
| 104 |
</div>
|
| 105 |
{% endif %}
|
| 106 |
</div>
|
| 107 |
+
<script>
|
| 108 |
+
document.addEventListener('DOMContentLoaded', function () {
|
| 109 |
+
const cards = document.querySelectorAll('.note-card[data-href]');
|
| 110 |
+
cards.forEach((card) => {
|
| 111 |
+
card.addEventListener('click', (event) => {
|
| 112 |
+
if (event.target.closest('a, button, input, select, textarea, form, label')) {
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
window.location.href = card.dataset.href;
|
| 116 |
+
});
|
| 117 |
+
card.addEventListener('keydown', (event) => {
|
| 118 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 119 |
+
event.preventDefault();
|
| 120 |
+
window.location.href = card.dataset.href;
|
| 121 |
+
}
|
| 122 |
+
});
|
| 123 |
+
});
|
| 124 |
+
});
|
| 125 |
+
</script>
|
| 126 |
+
{% endblock %}
|
app/templates/upload.html
CHANGED
|
@@ -11,6 +11,23 @@
|
|
| 11 |
<p class="font-mono text-sm text-gray-400">Share your knowledge with the community</p>
|
| 12 |
</div>
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
<form method="POST" enctype="multipart/form-data" class="space-y-6">
|
| 15 |
<div class="glass p-8 rounded-2xl space-y-6">
|
| 16 |
<!-- Title -->
|
|
@@ -64,6 +81,7 @@
|
|
| 64 |
<!-- Link Input -->
|
| 65 |
<div id="link_input" style="display: none;">
|
| 66 |
<label class="block font-mono text-sm text-gray-400 mb-2">Links (one per line)</label>
|
|
|
|
| 67 |
<textarea name="links" rows="5"
|
| 68 |
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono focus:outline-none focus:border-blue-500 transition"></textarea>
|
| 69 |
</div>
|
|
@@ -87,6 +105,7 @@
|
|
| 87 |
const dropzone = document.getElementById('file_dropzone');
|
| 88 |
const filesField = document.getElementById('files');
|
| 89 |
const fileCount = document.getElementById('selected_file_count');
|
|
|
|
| 90 |
|
| 91 |
function updateSelectedFileText(files) {
|
| 92 |
if (!files || files.length === 0) {
|
|
@@ -153,5 +172,12 @@
|
|
| 153 |
});
|
| 154 |
|
| 155 |
toggleInputType();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
</script>
|
| 157 |
{% endblock %}
|
|
|
|
| 11 |
<p class="font-mono text-sm text-gray-400">Share your knowledge with the community</p>
|
| 12 |
</div>
|
| 13 |
|
| 14 |
+
<div class="glass p-4 rounded-xl mb-6">
|
| 15 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 font-mono text-xs text-gray-300">
|
| 16 |
+
<div class="p-3 bg-white/5 rounded-lg border border-white/10">
|
| 17 |
+
<p class="text-blue-300 mb-1">Step 1</p>
|
| 18 |
+
<p>Set a clear title and choose the right subject tag.</p>
|
| 19 |
+
</div>
|
| 20 |
+
<div class="p-3 bg-white/5 rounded-lg border border-white/10">
|
| 21 |
+
<p class="text-blue-300 mb-1">Step 2</p>
|
| 22 |
+
<p>Upload files or paste one/many links (one per line).</p>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="p-3 bg-white/5 rounded-lg border border-white/10">
|
| 25 |
+
<p class="text-blue-300 mb-1">Step 3</p>
|
| 26 |
+
<p>Add short notes so other students know what this contains.</p>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
<form method="POST" enctype="multipart/form-data" class="space-y-6">
|
| 32 |
<div class="glass p-8 rounded-2xl space-y-6">
|
| 33 |
<!-- Title -->
|
|
|
|
| 81 |
<!-- Link Input -->
|
| 82 |
<div id="link_input" style="display: none;">
|
| 83 |
<label class="block font-mono text-sm text-gray-400 mb-2">Links (one per line)</label>
|
| 84 |
+
<p class="font-mono text-xs text-gray-500 mb-2">YouTube videos, playlists, docs, and normal URLs are supported.</p>
|
| 85 |
<textarea name="links" rows="5"
|
| 86 |
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 font-mono focus:outline-none focus:border-blue-500 transition"></textarea>
|
| 87 |
</div>
|
|
|
|
| 105 |
const dropzone = document.getElementById('file_dropzone');
|
| 106 |
const filesField = document.getElementById('files');
|
| 107 |
const fileCount = document.getElementById('selected_file_count');
|
| 108 |
+
const linksField = document.querySelector('textarea[name="links"]');
|
| 109 |
|
| 110 |
function updateSelectedFileText(files) {
|
| 111 |
if (!files || files.length === 0) {
|
|
|
|
| 172 |
});
|
| 173 |
|
| 174 |
toggleInputType();
|
| 175 |
+
|
| 176 |
+
linksField.addEventListener('focus', () => {
|
| 177 |
+
if (!noteTypeInput.value) {
|
| 178 |
+
noteTypeInput.value = 'link';
|
| 179 |
+
toggleInputType();
|
| 180 |
+
}
|
| 181 |
+
});
|
| 182 |
</script>
|
| 183 |
{% endblock %}
|
app/utilities/s3.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
import mimetypes
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import boto3
|
| 4 |
from botocore.config import Config
|
| 5 |
from flask import current_app
|
| 6 |
from flask_login import current_user
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def get_s3_client():
|
| 10 |
s3_config = Config(
|
|
@@ -26,6 +31,14 @@ def get_s3_client():
|
|
| 26 |
|
| 27 |
|
| 28 |
def generate_presigned_url(key, expiration=3600):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
s3_client = get_s3_client()
|
| 30 |
bucket = current_app.config["S3_BUCKET_NAME"]
|
| 31 |
|
|
@@ -35,6 +48,19 @@ def generate_presigned_url(key, expiration=3600):
|
|
| 35 |
ExpiresIn=expiration,
|
| 36 |
)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return presigned_url
|
| 39 |
|
| 40 |
|
|
|
|
| 1 |
import mimetypes
|
| 2 |
+
import time
|
| 3 |
+
from threading import Lock
|
| 4 |
|
| 5 |
import boto3
|
| 6 |
from botocore.config import Config
|
| 7 |
from flask import current_app
|
| 8 |
from flask_login import current_user
|
| 9 |
|
| 10 |
+
_PRESIGNED_URL_CACHE = {}
|
| 11 |
+
_CACHE_LOCK = Lock()
|
| 12 |
+
|
| 13 |
|
| 14 |
def get_s3_client():
|
| 15 |
s3_config = Config(
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def generate_presigned_url(key, expiration=3600):
|
| 34 |
+
cache_key = (key, int(expiration))
|
| 35 |
+
now = time.time()
|
| 36 |
+
|
| 37 |
+
with _CACHE_LOCK:
|
| 38 |
+
cached = _PRESIGNED_URL_CACHE.get(cache_key)
|
| 39 |
+
if cached and cached["expires_at"] > now:
|
| 40 |
+
return cached["url"]
|
| 41 |
+
|
| 42 |
s3_client = get_s3_client()
|
| 43 |
bucket = current_app.config["S3_BUCKET_NAME"]
|
| 44 |
|
|
|
|
| 48 |
ExpiresIn=expiration,
|
| 49 |
)
|
| 50 |
|
| 51 |
+
with _CACHE_LOCK:
|
| 52 |
+
# Keep a short buffer to avoid serving stale links close to expiry.
|
| 53 |
+
_PRESIGNED_URL_CACHE[cache_key] = {
|
| 54 |
+
"url": presigned_url,
|
| 55 |
+
"expires_at": now + max(1, int(expiration) - 30),
|
| 56 |
+
}
|
| 57 |
+
# Opportunistic cleanup to avoid unbounded growth.
|
| 58 |
+
expired_keys = [
|
| 59 |
+
k for k, v in _PRESIGNED_URL_CACHE.items() if v["expires_at"] <= now
|
| 60 |
+
]
|
| 61 |
+
for expired_key in expired_keys:
|
| 62 |
+
_PRESIGNED_URL_CACHE.pop(expired_key, None)
|
| 63 |
+
|
| 64 |
return presigned_url
|
| 65 |
|
| 66 |
|
config.py
CHANGED
|
@@ -1,10 +1,67 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
class Config:
|
| 5 |
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key"
|
| 6 |
|
| 7 |
-
SQLALCHEMY_DATABASE_URI =
|
| 8 |
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
| 9 |
|
| 10 |
# Google OAuth
|
|
|
|
| 1 |
import os
|
| 2 |
+
from urllib.parse import parse_qs, urlparse, urlunparse
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _normalize_sqlitecloud_uri(raw_uri):
|
| 6 |
+
uri = (raw_uri or "").strip()
|
| 7 |
+
if not uri:
|
| 8 |
+
return ""
|
| 9 |
+
|
| 10 |
+
# If a malformed value includes extra text before sqlitecloud://, keep only
|
| 11 |
+
# the driver URL portion.
|
| 12 |
+
idx = uri.find("sqlitecloud://")
|
| 13 |
+
if idx > 0:
|
| 14 |
+
uri = uri[idx:]
|
| 15 |
+
|
| 16 |
+
if not uri.startswith("sqlitecloud://"):
|
| 17 |
+
return uri
|
| 18 |
+
|
| 19 |
+
parsed = urlparse(uri)
|
| 20 |
+
query = parse_qs(parsed.query)
|
| 21 |
+
|
| 22 |
+
# Some copied strings accidentally place the actual URL in the apikey query.
|
| 23 |
+
apikey_values = query.get("apikey", [])
|
| 24 |
+
if apikey_values and apikey_values[0].startswith("sqlitecloud://"):
|
| 25 |
+
nested = apikey_values[0]
|
| 26 |
+
nested_idx = nested.find("sqlitecloud://")
|
| 27 |
+
if nested_idx >= 0:
|
| 28 |
+
return _normalize_sqlitecloud_uri(nested[nested_idx:])
|
| 29 |
+
|
| 30 |
+
db_name = os.environ.get("SQLITECLOUD_DB_NAME", "").strip()
|
| 31 |
+
path = parsed.path or ""
|
| 32 |
+
if path in ("", "/") and db_name:
|
| 33 |
+
path = f"/{db_name}"
|
| 34 |
+
|
| 35 |
+
return urlunparse(
|
| 36 |
+
(parsed.scheme, parsed.netloc, path, parsed.params, parsed.query, parsed.fragment)
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _default_database_uri():
|
| 41 |
+
explicit = os.environ.get("DATABASE_URL")
|
| 42 |
+
if explicit:
|
| 43 |
+
return _normalize_sqlitecloud_uri(explicit)
|
| 44 |
+
|
| 45 |
+
sqlitecloud_host = os.environ.get("SQLITECLOUD_HOST", "").strip()
|
| 46 |
+
sqlitecloud_api_key = os.environ.get("SQLITECLOUD_API_KEY", "").strip()
|
| 47 |
+
sqlitecloud_db_name = os.environ.get("SQLITECLOUD_DB_NAME", "porahobe").strip()
|
| 48 |
+
if sqlitecloud_host and sqlitecloud_api_key:
|
| 49 |
+
host = sqlitecloud_host.replace("sqlitecloud://", "").replace("https://", "")
|
| 50 |
+
host = host.split("?")[0].split("/")[0].split(":443")[0]
|
| 51 |
+
return f"sqlitecloud://{host}:8860/{sqlitecloud_db_name}?apikey={sqlitecloud_api_key}"
|
| 52 |
+
|
| 53 |
+
if os.path.isdir("/data"):
|
| 54 |
+
# Hugging Face Spaces persistent volume.
|
| 55 |
+
return "sqlite:////data/porahobebot.db"
|
| 56 |
+
|
| 57 |
+
# Local/dev fallback.
|
| 58 |
+
return "sqlite:///instance/app.db"
|
| 59 |
|
| 60 |
|
| 61 |
class Config:
|
| 62 |
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key"
|
| 63 |
|
| 64 |
+
SQLALCHEMY_DATABASE_URI = _default_database_uri()
|
| 65 |
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
| 66 |
|
| 67 |
# Google OAuth
|
docker-entrypoint.sh
CHANGED
|
@@ -3,6 +3,20 @@ set -e
|
|
| 3 |
|
| 4 |
mkdir -p instance
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
if [ "${RUN_MIGRATIONS:-1}" = "1" ]; then
|
| 7 |
if [ -d migrations ]; then
|
| 8 |
flask db upgrade
|
|
|
|
| 3 |
|
| 4 |
mkdir -p instance
|
| 5 |
|
| 6 |
+
# One-time migration for Spaces: move legacy ephemeral SQLite DB to persistent /data.
|
| 7 |
+
if [ -d "/data" ]; then
|
| 8 |
+
TARGET_DB="/data/porahobebot.db"
|
| 9 |
+
if [ ! -f "$TARGET_DB" ]; then
|
| 10 |
+
for CANDIDATE in "/home/user/app/app.db" "/home/user/app/instance/app.db" "app.db" "instance/app.db"; do
|
| 11 |
+
if [ -f "$CANDIDATE" ]; then
|
| 12 |
+
echo "Migrating legacy database from $CANDIDATE to $TARGET_DB"
|
| 13 |
+
cp "$CANDIDATE" "$TARGET_DB"
|
| 14 |
+
break
|
| 15 |
+
fi
|
| 16 |
+
done
|
| 17 |
+
fi
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
if [ "${RUN_MIGRATIONS:-1}" = "1" ]; then
|
| 21 |
if [ -d migrations ]; then
|
| 22 |
flask db upgrade
|
pyproject.toml
CHANGED
|
@@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
|
| 7 |
dependencies = [
|
| 8 |
"flask>=3.1.2",
|
| 9 |
"flask-sqlalchemy>=3.1.1",
|
|
|
|
| 10 |
"flask-migrate>=4.0.5",
|
| 11 |
"requests-oauthlib>=1.3.1",
|
| 12 |
"oauthlib>=3.2.2",
|
|
|
|
| 7 |
dependencies = [
|
| 8 |
"flask>=3.1.2",
|
| 9 |
"flask-sqlalchemy>=3.1.1",
|
| 10 |
+
"sqlalchemy-sqlitecloud",
|
| 11 |
"flask-migrate>=4.0.5",
|
| 12 |
"requests-oauthlib>=1.3.1",
|
| 13 |
"oauthlib>=3.2.2",
|
requirements.txt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
flask>=3.1.2
|
| 2 |
flask-sqlalchemy>=3.1.1
|
|
|
|
| 3 |
flask-migrate>=4.0.5
|
| 4 |
requests-oauthlib>=1.3.1
|
| 5 |
oauthlib>=3.2.2
|
|
|
|
| 1 |
flask>=3.1.2
|
| 2 |
flask-sqlalchemy>=3.1.1
|
| 3 |
+
sqlalchemy-sqlitecloud
|
| 4 |
flask-migrate>=4.0.5
|
| 5 |
requests-oauthlib>=1.3.1
|
| 6 |
oauthlib>=3.2.2
|