diff --git a/README.md b/README.md index 4d55215..8473777 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Transfemme voice-training web app. - Frontend: Vite/React static build in `dist/` - Backend: Flask/Gunicorn WSGI app in `server/` - Auth: first-party FemmeVoice username and passphrase accounts with server-side sessions -- Database: MongoDB progress storage +- Database: MongoDB for accounts, progress, governed Academy content, and separate private Academy media storage - Service: Gunicorn/Flask backend - Admin feedback inbox: server-side administrator allowlist via `FEMMEVOICE_ADMIN_USERNAMES` (comma-separated lowercase usernames); never grant admin access from the browser diff --git a/docs/media-pipeline.md b/docs/media-pipeline.md index 8727c46..1485737 100644 --- a/docs/media-pipeline.md +++ b/docs/media-pipeline.md @@ -35,7 +35,21 @@ The Admin Media Library provides structured fields for identity, source, checksu The HTML application shell is served with `no-cache` so a deployment immediately discovers the new hashed frontend bundle. This does not disable public API caching: content and media manifests retain their independent cache and stale-while-revalidate policies. -Binary storage remains separate from metadata governance. Teaching files and caption tracks currently require same-origin paths, matching the app's restrictive content-security policy and avoiding third-party learner requests. External HTTPS URLs are accepted only as rights/source attribution. Educational media does not use the encrypted learner-recording vault. A later Milestone 6 slice must choose an upload/CDN strategy and an explicit origin policy before large production audio or video is accepted. +Binary storage remains separate from metadata governance. Teaching files and caption tracks require same-origin paths, matching the app's restrictive content-security policy and avoiding third-party learner requests. External HTTPS URLs are accepted only as rights/source attribution. Educational media does not use the encrypted learner-recording vault. + +## File Storage And Delivery + +Academy authors can upload supported raster images, audio, video, WebVTT captions, and PDF resources through the Media Library. The backend streams each upload through a temporary spool, enforces a 100 MB limit, verifies a conservative MIME signature, computes SHA-256, and deduplicates matching content in a separate `academy_media_files` GridFS bucket. + +An uploaded file URL returns 404 until a published media revision references it as the teaching source or caption track. Public delivery then provides: + +- the reviewed MIME type and a safe filename; +- a checksum ETag and conditional `304` responses; +- one-year immutable caching because the file id never changes; +- byte-range requests for audio/video seeking; +- attachment delivery for PDFs and inline delivery for lesson media. + +The encrypted private-recording bucket is never reused. Abandoned draft uploads require a future cleanup job. GridFS is the current storage adapter, not a permanent mandate: an object-storage/CDN adapter should be reviewed before high-volume or larger video libraries are introduced. ## Lesson References diff --git a/docs/roadmap.md b/docs/roadmap.md index 64932b7..8d32925 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,19 +2,19 @@ **Status:** living implementation guide **Last updated:** 17 July 2026 -**Current development phase:** Milestone 6 educational media governance and delivery +**Current development phase:** Milestone 6 complete; ready for the remaining Foundations curriculum ## Active Development | Item | Current state | | --- | --- | -| Current milestone | Milestone 6 - Educational Media Pipeline | -| Current version | `v0.3.33` | -| Current working branch | `feat/academy-media-resolution` | -| Active pull request(s) | PR #21 - Deterministic governed lesson-media resolution; PR #20 is integrated into `main`. | +| Current milestone | Milestone 6 - Educational Media Pipeline completed | +| Current version | `v0.3.34` | +| Current working branch | `main` after the Milestone 6 completion integration | +| Active pull request(s) | None after PR #22 - Private Academy media storage and immutable delivery - integrates into `main`. | | Base branch | `main` is canonical. | -| Next planned milestone | Continue Milestone 6 with deterministic lesson asset references and public resolution. | -| Overall completion estimate | About 40% of the long-term Academy vision; engine, history, governance, authoring, and delivery are complete, while media, remaining curriculum, and coaching remain substantial work. | +| Next planned milestone | Milestone 7 - Remaining Foundations Lessons | +| Overall completion estimate | About 45% of the long-term Academy vision; engine, history, governance, authoring, and media delivery are complete, while the full curriculum, interactive practice, and coaching remain substantial work. | ## Vision @@ -57,6 +57,7 @@ The definitive product direction is the [Product Vision](product-vision.md). Voi - `v0.3.31` - Governed Academy media library and publication workflow - `v0.3.32` - Governed media-library pull-request readiness - `v0.3.33` - Deterministic governed lesson-media resolution +- `v0.3.34` - Private Academy media storage and immutable delivery Update this list whenever a versioned change is pushed so milestones, pull requests, and releases remain easy to correlate. @@ -417,7 +418,7 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda ### Milestone 6 - Educational Media Pipeline -**Status:** 🚧 In Progress +**Status:** ✅ Completed **Goal:** Produce and maintain reviewed, accessible educational assets without treating media as an afterthought. **Complexity:** High **Dependencies:** Milestone 5 @@ -425,10 +426,10 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda **Why here:** The engine and authoring workflow must be stable before investing in a larger asset library. Content operations gives every asset a durable review, revision, and publication path. **Acceptance criteria** -- [ ] Audio, video, illustration, caption, transcript, and localization assets have clear ownership and version linkage. -- [ ] Research, content, and accessibility review are recorded before publication. -- [ ] Assets have replacement, correction, and localization workflows rather than being embedded as one-off files. -- [ ] No production teaching media is published without its required accessible alternatives. +- [x] Audio, video, illustration, caption, transcript, and localization assets have clear ownership and version linkage. +- [x] Research, content, and accessibility review are recorded before publication. +- [x] Assets have replacement, correction, and localization workflows rather than being embedded as one-off files. +- [x] No production teaching media is published without its required accessible alternatives. **In progress** - [x] Versioned media contract covers locale, source, rights, accessibility metadata, and three-part review. @@ -439,7 +440,17 @@ Milestone 5 ends when a contributor can comfortably maintain the complete Founda - [x] Lesson media blocks reference deterministic governed asset revisions and resolve them through the public manifest. - [x] Public delivery retains every immutable published revision so older lesson pins remain reproducible after replacement. - [x] Validate exact-revision resolution and bundled fallback through the staging learner experience. -- [ ] Validate localization resolution with real Foundations lesson content. +- [x] Validate replacement and localization revisions with the real Foundations pathway asset. +- [x] Author uploads are streamed into a separate Academy GridFS bucket with a 100 MB limit, MIME signature checks, SHA-256 deduplication, and draft-private access. +- [x] Published files support immutable ETags, conditional requests, and byte ranges for audio/video seeking. + +### Milestone 6 Retrospective + +- **Accomplished:** governed media drafts and review, immutable public manifests, exact lesson revision pins, resilient bundled fallback, author uploads, caption upload support, replacement lineage, localization lineage, and cacheable range delivery. +- **Key decision:** project teaching assets use a separate Academy GridFS bucket. Learner recordings remain isolated and encrypted; a file becomes public only when an approved published media revision references its exact immutable id. +- **Learned:** retaining only the newest media revision would break reproducible lesson pins, and accepting arbitrary HTTPS files conflicted with the same-origin content-security policy. The manifest now retains every published revision and delivery stays same-origin. +- **Technical debt:** abandoned draft uploads need a scheduled orphan-cleanup job. Mongo/GridFS is appropriate for the current free curriculum and 100 MB asset limit, but high-volume or very large video should move behind a reviewed object-storage/CDN adapter later. +- **Next:** Milestone 7 should use the complete authoring and media workflow to finish Foundations. Interactive Practice remains a separate backlog item and should be designed from real exercise requirements rather than interrupting curriculum work. ### Milestone 7 - Remaining Foundations Lessons @@ -480,7 +491,8 @@ The existing `interactive_exercise` block remains suitable for simple learner-le - Academy history must remain a separate versioned contract. Milestone 4A establishes useful local history; Milestone 4B owns migration, retention, export, deletion, multi-device conflict handling, and explicit sync consent. - Existing production course records require the documented one-time version-index migration before a second course revision is saved. - Public media needs a storage/CDN strategy; do not put large course video into the existing encrypted-recording vault. -- Media metadata governance is implemented, but binary upload/storage is intentionally pending a dedicated storage decision. Current assets use reviewed local or HTTPS sources. +- Abandoned Academy draft uploads need a bounded cleanup job; public files cannot be deleted while a published immutable revision references them. +- Academy GridFS delivery is capped at 100 MB per asset. Revisit an object-storage/CDN adapter before publishing large or high-traffic video libraries. - Lesson authoring must enforce captions, transcripts, evidence, and safety metadata, or content quality will drift. - Community features require funded human moderation and safeguarding; they are not a learner MVP dependency. - Native iOS work depends on later API-token and audio-provider boundaries described in the iOS readiness plan. @@ -544,3 +556,4 @@ The existing `interactive_exercise` block remains suitable for simple learner-le - **2026-07-17:** Milestone 6 begins with a governed media library. Authors can save incomplete private drafts, but only real checksummed assets with complete rights and kind-specific accessibility metadata may enter role-separated review and publication. Published metadata is exposed through a cacheable read-only manifest; lesson resolution follows in the next focused slice. - **2026-07-17:** Governed lesson media uses an exact `{id, version, locale}` reference plus a bundled fallback source. The public manifest retains every published immutable revision, and the learner resolver uses only a kind-matched, fully reviewed exact revision; unavailable delivery never blanks a lesson. - **2026-07-17:** Staging published the real “How voice learning works” lesson and a one-lesson Foundations course through the author, reviewer, and publisher identities. The learner player consumed the pinned media revision and its governed alternative text on desktop and 390px mobile. HTML now uses `no-cache` so deployments discover hashed bundles while public manifests remain cacheable. +- **2026-07-17:** Milestone 6 completed with separate private Academy GridFS storage, strict upload validation, draft-private file access, published immutable range delivery, checksum deduplication, and real pathway illustration v2/replacement plus Finnish localization validation. Remaining large-video/CDN and orphan-cleanup work is recorded as technical debt rather than hidden scope. diff --git a/docs/staging-academy.md b/docs/staging-academy.md index 1502523..69759a2 100644 --- a/docs/staging-academy.md +++ b/docs/staging-academy.md @@ -15,3 +15,5 @@ The Milestone 5 validation used separate author, reviewer, and publisher session Milestone 6 media validation uses the same identities in the **Educational media** section. Save a private asset draft as the author, submit it only after its source, checksum, rights, and accessible alternative are complete, approve all three review areas as the reviewer, and publish as the publisher. `GET /api/academy/media` must remain empty before publication and return the published revision with `Cache-Control: public, max-age=300, stale-while-revalidate=3600` afterward. Published media fields are read-only; replacements and localizations begin as new private revisions. To validate lesson delivery, pin the asset's exact id, version, and locale in a lesson media block while retaining a bundled fallback URL. Publish that lesson and its course through the same three identities. The learner player must use the governed source and accessibility metadata when the exact revision is present, and preserve the bundled block unchanged when it is absent. The public manifest intentionally retains older published revisions after replacements so existing lesson pins remain reproducible. + +To validate storage, upload the pathway illustration as the author and confirm its returned `/api/academy/media/files/...` URL is 404 before publication. Publish a replacement media revision, then confirm the file returns its original bytes, checksum ETag, immutable cache header, and `206 Partial Content` for a byte-range request. Uploading the same file again should return the same source id. A localized asset may reuse that immutable file while supplying separately reviewed localized accessibility text. diff --git a/package-lock.json b/package-lock.json index bfc0e12..72854bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "femmevoice", - "version": "0.3.33", + "version": "0.3.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "femmevoice", - "version": "0.3.33", + "version": "0.3.34", "dependencies": { "@vitejs/plugin-react": "^5.0.0", "lucide-react": "^0.468.0", diff --git a/package.json b/package.json index c59b498..6f11855 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "femmevoice", - "version": "0.3.33", + "version": "0.3.34", "private": true, "type": "module", "scripts": { diff --git a/server/academy_media_files.py b/server/academy_media_files.py new file mode 100644 index 0000000..2536894 --- /dev/null +++ b/server/academy_media_files.py @@ -0,0 +1,61 @@ +ACADEMY_MEDIA_FILE_LIMIT = 100 * 1024 * 1024 + +ALLOWED_MEDIA_MIMES = { + "image/jpeg", "image/png", "image/webp", + "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/webm", + "video/mp4", "video/webm", + "text/vtt", "application/pdf", +} + + +def validate_academy_media_file(mime_type, leading_bytes, size): + if mime_type not in ALLOWED_MEDIA_MIMES: + raise ValueError("Choose a supported image, audio, video, caption, or PDF file.") + if not isinstance(size, int) or size < 1 or size > ACADEMY_MEDIA_FILE_LIMIT: + raise ValueError("Academy media files must be between 1 byte and 100 MB.") + signatures = { + "image/jpeg": lambda data: data.startswith(b"\xff\xd8\xff"), + "image/png": lambda data: data.startswith(b"\x89PNG\r\n\x1a\n"), + "image/webp": lambda data: data.startswith(b"RIFF") and data[8:12] == b"WEBP", + "audio/mpeg": lambda data: data.startswith(b"ID3") or (len(data) > 1 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0), + "audio/wav": lambda data: data.startswith(b"RIFF") and data[8:12] == b"WAVE", + "audio/ogg": lambda data: data.startswith(b"OggS"), + "audio/mp4": _is_mp4, + "audio/webm": lambda data: data.startswith(b"\x1a\x45\xdf\xa3"), + "video/mp4": _is_mp4, + "video/webm": lambda data: data.startswith(b"\x1a\x45\xdf\xa3"), + "text/vtt": lambda data: data.lstrip(b"\xef\xbb\xbf \t\r\n").startswith(b"WEBVTT"), + "application/pdf": lambda data: data.startswith(b"%PDF-"), + } + if not signatures[mime_type](leading_bytes): + raise ValueError("The file contents do not match the selected media type.") + return mime_type + + +def parse_byte_range(header, length): + if not header: + return None + if not isinstance(length, int) or length < 1 or not header.startswith("bytes=") or "," in header: + raise ValueError("Unsupported byte range.") + start_text, separator, end_text = header[6:].partition("-") + if not separator or (not start_text and not end_text): + raise ValueError("Unsupported byte range.") + try: + if not start_text: + suffix = int(end_text) + if suffix < 1: + raise ValueError + start, end = max(0, length - suffix), length - 1 + else: + start = int(start_text) + end = int(end_text) if end_text else length - 1 + if start < 0 or start >= length or end < start: + raise ValueError + end = min(end, length - 1) + except (TypeError, ValueError): + raise ValueError("Unsupported byte range.") from None + return start, end + + +def _is_mp4(data): + return len(data) >= 12 and data[4:8] == b"ftyp" diff --git a/server/app.py b/server/app.py index b45ec27..d80108e 100644 --- a/server/app.py +++ b/server/app.py @@ -6,22 +6,25 @@ import urllib.parse import urllib.request import uuid +import tempfile from datetime import datetime, timedelta, timezone from email.message import EmailMessage from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from bson import ObjectId -from flask import Flask, Response, jsonify, redirect, request, send_from_directory, session +from flask import Flask, Response, jsonify, redirect, request, send_from_directory, session, stream_with_context from gridfs import GridFSBucket from gridfs.errors import NoFile from pymongo import ASCENDING, MongoClient from pymongo.errors import DuplicateKeyError, PyMongoError from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename from academy_history import normalize_academy_history from academy_content import build_public_catalogue, can_submit_for_review, resolve_published_lesson_refs, review_result_status, validate_course_document, validate_lesson_document, validate_review from academy_media import build_public_media_manifest, media_review_result_status, validate_media_asset, validate_media_review +from academy_media_files import ACADEMY_MEDIA_FILE_LIMIT, parse_byte_range, validate_academy_media_file from reminder_logic import VALID_REMINDER_TONES, normalize_reminder_days ROOT = Path(__file__).resolve().parent.parent @@ -86,7 +89,9 @@ academy_lessons_collection = db["academy_lessons"] academy_courses_collection = db["academy_courses"] academy_media_collection = db["academy_media"] +academy_media_files_collection = db["academy_media_files.files"] recordings_bucket = GridFSBucket(db, bucket_name="private_recordings") +academy_media_bucket = GridFSBucket(db, bucket_name="academy_media_files") progress_collection.create_index([("device_id", ASCENDING)]) progress_collection.create_index([("storage_key", ASCENDING)], unique=True) users_collection.create_index([("username_normalized", ASCENDING)], unique=True) @@ -118,9 +123,10 @@ def apply_security_headers(response): if request.is_secure: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" public_academy_paths = {"/api/academy/content", "/api/academy/media"} + public_academy_request = request.path in public_academy_paths or request.path.startswith("/api/academy/media/files/") if response.mimetype == "text/html": response.headers["Cache-Control"] = "no-cache" - elif request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or (request.path.startswith("/api/academy/") and request.path not in public_academy_paths) or request.path.startswith("/api/account/academy-history"): + elif request.path.startswith("/api/auth/") or request.path.startswith("/api/privacy/") or request.path.startswith("/api/recordings") or request.path.startswith("/api/admin/") or (request.path.startswith("/api/academy/") and not public_academy_request) or request.path.startswith("/api/account/academy-history"): response.headers["Cache-Control"] = "no-store" return response @@ -567,6 +573,59 @@ def public_academy_media(): return response +@app.get("/api/academy/media/files/") +def public_academy_media_file(file_id): + source = f"/api/academy/media/files/{file_id}" + if not academy_media_collection.find_one({"status": "published", "$or": [{"asset.source": source}, {"asset.accessibility.captions": source}]}, {"_id": 1}): + return auth_error("Published Academy media was not found.", 404) + try: + stream = academy_media_bucket.open_download_stream(ObjectId(file_id)) + except Exception: + return auth_error("Published Academy media is unavailable.", 404) + + metadata = stream.metadata or {} + checksum = metadata.get("checksum", "").removeprefix("sha256:") + if checksum and request.if_none_match.contains(checksum): + stream.close() + response = Response(status=304, mimetype=metadata.get("mime_type", "application/octet-stream")) + response.set_etag(checksum) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + try: + byte_range = parse_byte_range(request.headers.get("Range"), stream.length) + except ValueError: + stream.close() + response = Response(status=416) + response.headers["Content-Range"] = f"bytes */{stream.length}" + return response + start, end = byte_range or (0, stream.length - 1) + stream.seek(start) + + def chunks(): + remaining = end - start + 1 + try: + while remaining > 0: + chunk = stream.read(min(64 * 1024, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + finally: + stream.close() + + response = Response(stream_with_context(chunks()), status=206 if byte_range else 200, mimetype=metadata.get("mime_type", "application/octet-stream")) + response.content_length = end - start + 1 + if checksum: + response.set_etag(checksum) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + response.headers["Accept-Ranges"] = "bytes" + if byte_range: + response.headers["Content-Range"] = f"bytes {start}-{end}/{stream.length}" + disposition = "attachment" if metadata.get("mime_type") == "application/pdf" else "inline" + response.headers["Content-Disposition"] = f'{disposition}; filename="{metadata.get("filename", "academy-media")}"' + return response + + @app.get("/api/admin/academy/media") def list_academy_media_for_admin(): user = user_from_request() @@ -576,6 +635,51 @@ def list_academy_media_for_admin(): return jsonify({"roles": user["academy_roles"], "assets": records}) +@app.post("/api/admin/academy/media/files") +def upload_academy_media_file(): + if not csrf_required(): + return auth_error("Your session expired. Refresh and try again.", 403) + user = academy_user_with_role("author") + if not user: + return auth_error("Academy author access is required.", 403) + if request.content_length and request.content_length > ACADEMY_MEDIA_FILE_LIMIT + 1_000_000: + return auth_error("Academy media files must be 100 MB or smaller.", 413) + upload = request.files.get("file") + if not upload or not upload.filename: + return auth_error("Choose a media file to upload.") + filename = secure_filename(upload.filename)[:180] or "academy-media" + mime_type = (upload.mimetype or "").lower() + digest = hashlib.sha256() + size = 0 + try: + with tempfile.SpooledTemporaryFile(max_size=8 * 1024 * 1024) as staged: + while True: + chunk = upload.stream.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + if size > ACADEMY_MEDIA_FILE_LIMIT: + return auth_error("Academy media files must be 100 MB or smaller.", 413) + digest.update(chunk) + staged.write(chunk) + staged.seek(0) + leading_bytes = staged.read(32) + try: + validate_academy_media_file(mime_type, leading_bytes, size) + except ValueError as error: + return auth_error(str(error)) + checksum = f"sha256:{digest.hexdigest()}" + existing = academy_media_files_collection.find_one({"metadata.checksum": checksum, "metadata.mime_type": mime_type}) + if existing: + file_id = existing["_id"] + else: + staged.seek(0) + file_id = academy_media_bucket.upload_from_stream(filename, staged, metadata={"filename": filename, "mime_type": mime_type, "byte_size": size, "checksum": checksum, "uploaded_by": user["username"], "uploaded_at": now_iso()}) + except Exception: + return auth_error("The Academy media file could not be stored.", 503) + return jsonify({"file": {"source": f"/api/academy/media/files/{file_id}", "filename": filename, "mimeType": mime_type, "byteSize": size, "checksum": checksum}}), 201 + + @app.get("/api/admin/academy/media///") def get_academy_media_for_admin(asset_id, version, locale): user = user_from_request() diff --git a/server/test_academy_media_files.py b/server/test_academy_media_files.py new file mode 100644 index 0000000..d233ba6 --- /dev/null +++ b/server/test_academy_media_files.py @@ -0,0 +1,33 @@ +import unittest + +from academy_media_files import ACADEMY_MEDIA_FILE_LIMIT, parse_byte_range, validate_academy_media_file + + +class AcademyMediaFileTests(unittest.TestCase): + def test_accepts_supported_file_signatures(self): + self.assertEqual(validate_academy_media_file("image/jpeg", b"\xff\xd8\xff\xe0", 4), "image/jpeg") + self.assertEqual(validate_academy_media_file("image/png", b"\x89PNG\r\n\x1a\n", 8), "image/png") + self.assertEqual(validate_academy_media_file("video/mp4", b"\x00\x00\x00\x18ftypisom", 12), "video/mp4") + self.assertEqual(validate_academy_media_file("text/vtt", b"WEBVTT\n\n", 8), "text/vtt") + + def test_rejects_unsupported_mime_or_mismatched_contents(self): + with self.assertRaisesRegex(ValueError, "supported"): + validate_academy_media_file("image/svg+xml", b" validateMediaAsset(asset), [asset]); const publicationValidation = useMemo(() => validateMediaAsset(asset, { publicationReady: true }), [asset]); @@ -31,6 +33,8 @@ export default function AdminMediaLibrary({ roles }) { setAssetStatus("draft"); setReview({ ...EMPTY_REVIEW }); setSaved(false); + setSourceFile(null); + setCaptionFile(null); setStatus(message); } @@ -64,6 +68,25 @@ export default function AdminMediaLibrary({ roles }) { } catch (error) { setStatus(error.message); } } + async function uploadFile(file, destination) { + if (!file || !editable) return; + try { + setStatus(`Uploading ${file.name}...`); + const result = await uploadAcademyAdminMediaFile(file); + update((next) => { + if (destination === "captions") next.accessibility.captions = result.file.source; + else { + next.source = result.file.source; + next.mimeType = result.file.mimeType; + next.byteSize = result.file.byteSize; + next.checksum = result.file.checksum; + } + }); + if (destination === "captions") setCaptionFile(null); else setSourceFile(null); + setStatus(`${file.name} uploaded privately. Save and publish its media revision before learners can access it.`); + } catch (error) { setStatus(error.message); } + } + async function transition(action) { try { const result = action === "submit" @@ -87,8 +110,8 @@ export default function AdminMediaLibrary({ roles }) { {!asset ?

Create or choose an asset

Start with the illustration, audio, video, document, transcript, and rights information you already have. Review happens only after the draft is complete.

:

{formatStatus(assetStatus)}

{asset.title}

{assetStatus === "published" && roles.includes("author") &&
}
-
Media metadata
update((next) => { next.title = value; })} required /> update((next) => { next.id = value; })} required /> update((next) => { next.version = Number(value); })} required /> update((next) => { next.locale = value; })} required /> update((next) => { next.source = value; })} hint="Use a same-origin path such as /academy/media/example.jpg." required /> update((next) => { next.mimeType = value; })} required /> update((next) => { next.byteSize = Number(value); })} required /> update((next) => { next.checksum = value; })} required />
-
Rights update((next) => { next.rights.owner = value; })} required /> update((next) => { next.rights.license = value; })} required /> update((next) => { next.rights.attribution = value; })} multiline /> update((next) => { next.rights.sourceUrl = value; })} />
Accessible alternatives{asset.kind === "image" && update((next) => { next.accessibility.alternative = value; })} multiline required />}{["audio", "video"].includes(asset.kind) && update((next) => { next.accessibility.transcript = value; })} multiline required />}{asset.kind === "video" && update((next) => { next.accessibility.captions = value; })} required />} update((next) => { next.accessibility.longDescription = value; })} multiline />
+
Media metadata{editable &&
}
update((next) => { next.title = value; })} required /> update((next) => { next.id = value; })} required /> update((next) => { next.version = Number(value); })} required /> update((next) => { next.locale = value; })} required /> update((next) => { next.source = value; })} hint="Filled automatically after upload, or use an existing same-origin path." required /> update((next) => { next.mimeType = value; })} required /> update((next) => { next.byteSize = Number(value); })} required /> update((next) => { next.checksum = value; })} required />
+
Rights update((next) => { next.rights.owner = value; })} required /> update((next) => { next.rights.license = value; })} required /> update((next) => { next.rights.attribution = value; })} multiline /> update((next) => { next.rights.sourceUrl = value; })} />
Accessible alternatives{asset.kind === "image" && update((next) => { next.accessibility.alternative = value; })} multiline required />}{["audio", "video"].includes(asset.kind) && update((next) => { next.accessibility.transcript = value; })} multiline required />}{asset.kind === "video" && <> update((next) => { next.accessibility.captions = value; })} required />{editable &&
}} update((next) => { next.accessibility.longDescription = value; })} multiline />

{editable && <>}{assetStatus === "review_requested" && roles.includes("reviewer") && transition("review")} />}{assetStatus === "in_review" && roles.includes("publisher") && }
} @@ -110,3 +133,7 @@ function MediaField({ label, value, onChange, multiline = false, required = fals function formatStatus(status) { return ({ draft: "Draft", review_requested: "Review requested", in_review: "Approved for publication", published: "Published" })[status] ?? status; } + +function acceptForKind(kind) { + return ({ image: "image/jpeg,image/png,image/webp", audio: "audio/mpeg,audio/wav,audio/ogg,audio/mp4,audio/webm", video: "video/mp4,video/webm", document: "application/pdf,text/vtt,.vtt" })[kind] ?? ""; +} diff --git a/src/api.js b/src/api.js index 8daeea2..2dcf051 100644 --- a/src/api.js +++ b/src/api.js @@ -213,6 +213,12 @@ export async function listAcademyAdminMedia() { return response.json(); } +export async function uploadAcademyAdminMediaFile(file) { + const body = new FormData(); + body.append("file", file); + return secureRequest("/api/admin/academy/media/files", { method: "POST", body }); +} + export async function loadAcademyAdminMedia(id, version, locale) { const response = await fetch(`/api/admin/academy/media/${encodeURIComponent(id)}/${version}/${encodeURIComponent(locale)}`, { headers: { Accept: "application/json" } }); if (!response.ok) throw new Error("Could not load this media revision."); diff --git a/src/styles.css b/src/styles.css index ad8691e..47e8668 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2628,6 +2628,8 @@ canvas { .admin-media-editor { display: grid; gap: 16px; } .admin-media-fields { display: grid; gap: 16px; margin: 0; border: 0; padding: 0; } .admin-media-fields:disabled { opacity: .72; } +.admin-media-upload, .admin-caption-upload { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; border-left: 3px solid #218e7e; background: #f2f8f4; padding: 12px; } +.admin-media-upload label, .admin-caption-upload label { flex: 1 1 220px; } .admin-media-editor input, .admin-media-editor textarea, .admin-media-editor select { width: 100%; border: 1px solid rgba(35,38,34,.25); border-radius: 6px; padding: 10px; background: #fff; font: inherit; } .admin-media-editor label { display: grid; gap: 6px; font-weight: 740; } .admin-media-empty { display: grid; min-height: 220px; align-content: center; gap: 8px; color: #59645e; line-height: 1.45; } diff --git a/src/version.js b/src/version.js index 7c6b1a8..7ed8e1c 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const APP_VERSION = "0.3.33"; +export const APP_VERSION = "0.3.34";