SpreadSheets600 commited on
Commit
1476658
·
1 Parent(s): 24c4edb

Upgrade DB, previews, and student UX flow

Browse files
.env.example CHANGED
@@ -1,5 +1,11 @@
1
  SECRET_KEY=change_me
2
- DATABASE_URL=sqlite:///app.db
 
 
 
 
 
 
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:///app.db
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` (optional, defaults to SQLite)
 
 
 
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
- notes = query.order_by(Note.created_at.desc()).all()
 
 
 
 
 
 
 
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
- return render_template("note_preview.html", note=note)
 
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.preview', 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
- Preview
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
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="glass p-4 sm:p-8 rounded-2xl mb-8" id="preview-container">
46
- {% if note.original_link %}
47
- <div id="link-preview" class="text-center py-8" data-url="{{ note.presigned_url }}">
48
- <p class="font-mono text-sm text-gray-400 mb-4">External Link</p>
49
- <a href="{{ note.presigned_url }}" target="_blank"
50
- class="inline-flex items-center gap-2 font-mono text-blue-400 hover:text-blue-300 transition break-all">
51
- {{ note.presigned_url }}
52
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
54
- d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
55
- </svg>
56
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
58
- {% else %}
59
- {% set ext = note.link.split('.')[-1].lower() %}
60
 
61
- {% if ext in ['jpg', 'jpeg', 'png', 'gif', 'webp'] %}
62
- <img src="{{ note.presigned_url }}" class="w-full rounded-xl" alt="{{ note.title }}">
63
- {% elif ext == 'pdf' %}
64
- <iframe src="{{ note.presigned_url }}" class="w-full h-[600px] rounded-xl bg-white/5"></iframe>
65
- {% elif ext in ['mp4', 'webm', 'ogg'] %}
66
- <video controls class="w-full rounded-xl">
67
- <source src="{{ note.presigned_url }}" type="video/{{ ext }}">
68
- </video>
69
- {% elif ext in ['mp3', 'wav', 'ogg'] %}
70
- <audio controls class="w-full">
71
- <source src="{{ note.presigned_url }}" type="audio/{{ ext }}">
72
- </audio>
73
- {% elif ext in ['txt', 'md'] %}
74
- <iframe src="{{ note.presigned_url }}" class="w-full h-[600px] rounded-xl bg-white/5"></iframe>
75
- {% else %}
76
- <div class="text-center py-12">
77
- <p class="font-mono text-gray-400 mb-4">Preview not available for this file type</p>
78
- <p class="font-mono text-sm text-gray-500">{{ note.link.split('/')[-1] }}</p>
79
- </div>
80
- {% endif %}
81
- {% endif %}
 
 
 
 
 
 
 
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-4 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,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.preview', 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">
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
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = os.environ.get("DATABASE_URL") or "sqlite:///app.db"
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