diff --git a/scenes/urls.py b/scenes/urls.py index 6b76988..8d14276 100644 --- a/scenes/urls.py +++ b/scenes/urls.py @@ -4,6 +4,8 @@ from . import views urlpatterns = [ path("me/", views.me), + path("login/", views.login_view), + path("logout/", views.logout_view), path("projects/", views.projects), path("projects//", views.project), path("projects//scene/", views.scene), diff --git a/scenes/views.py b/scenes/views.py index f7aaed2..2f6dd91 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -1,5 +1,6 @@ import json +from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout from django.db.models import Q from django.http import HttpResponseNotAllowed, JsonResponse from django.shortcuts import get_object_or_404 @@ -42,11 +43,34 @@ def _meta(request, project): } +def _who(user): + return {"authenticated": user.is_authenticated, + "username": user.get_username() if user.is_authenticated else None} + + def me(request): """GET /api/me/ — who the browser is logged in as (for the UI to gate edits).""" - u = request.user - return JsonResponse({"authenticated": u.is_authenticated, - "username": u.get_username() if u.is_authenticated else None}) + return JsonResponse(_who(request.user)) + + +@csrf_exempt +def login_view(request): + """POST /api/login/ {username, password} — start a session (httpOnly cookie).""" + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + data = json.loads(request.body or "{}") + user = authenticate(request, username=data.get("username"), password=data.get("password")) + if user is None: + return JsonResponse({"detail": "invalid username or password"}, status=400) + dj_login(request, user) + return JsonResponse(_who(user)) + + +@csrf_exempt +def logout_view(request): + """POST /api/logout/ — end the session.""" + dj_logout(request) + return JsonResponse(_who(request.user)) @csrf_exempt diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index cdd6514..351ba36 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -275,3 +275,25 @@ body { overflow: hidden; } .divider-h { height: 14px; } /* bigger touch target (top vs timeline) */ .slider input { width: 120px; } } + +/* --- landing pages (project list / create) ----------------------------- */ +.home { min-height: 100vh; box-sizing: border-box; max-width: 680px; margin: 0 auto; + padding: 32px 20px; color: #ddd; font-family: sans-serif; background: #0c0c0c; } +.home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; } +.home-head h1 { font-size: 22px; margin: 0; color: #cfe3f5; letter-spacing: 1px; } +.home a { color: #7aa6d6; text-decoration: none; } +.home a:hover { text-decoration: underline; } +.muted { color: #888; font-size: 12px; } +.home .add-btn { display: inline-block; width: auto; margin-bottom: 12px; } +.project-list { list-style: none; padding: 0; margin: 12px 0; } +.project-list li { padding: 10px 12px; border: 1px solid #2a2a2a; border-radius: 6px; + margin-bottom: 8px; background: #161616; } +.project-list li a { font-size: 15px; color: #cfe3f5; } +.home .form { background: transparent; border: none; padding: 0; } +.file-row { display: block; margin: 12px 0; color: #cfe3f5; font-size: 13px; } +.err { color: #e66; font-size: 12px; margin: 8px 0; } + +/* editor: project bar above the timeline */ +.project-bar { display: flex; gap: 10px; align-items: center; padding: 4px 8px; + background: #111; border-bottom: 1px solid #222; font-size: 12px; } +.project-bar .project-name { color: #cfe3f5; font-weight: 600; } diff --git a/tl/src/tl/api.cljs b/tl/src/tl/api.cljs new file mode 100644 index 0000000..babc0ad --- /dev/null +++ b/tl/src/tl/api.cljs @@ -0,0 +1,73 @@ +(ns tl.api + "Thin fetch wrapper around the Django backend. Functions dispatch re-frame + events on completion (qualified keywords, to avoid a require cycle with + tl.events). Same host as the page, port 8000, with the session cookie." + (:require [re-frame.core :as rf])) + +(def base + (str (.. js/window -location -protocol) "//" (.. js/window -location -hostname) ":8000")) + +(defn- ->clj [x] (js->clj x :keywordize-keys true)) + +(defn- GET [path] + (-> (js/fetch (str base path) #js {:credentials "include"}) + (.then (fn [r] (when (.-ok r) (.json r)))) + (.then ->clj))) + +(defn- POST [path body on-done] + (-> (js/fetch (str base path) + #js {:method "POST" :credentials "include" + :headers #js {"Content-Type" "application/json"} + :body (js/JSON.stringify (clj->js body))}) + (.then (fn [r] (.then (.json r) #(on-done (.-ok r) (->clj %))))))) + +(defn fetch-me! [] + (-> (GET "/api/me/") (.then #(rf/dispatch [:tl.events/set-auth %])))) + +(defn login! [username password] + (POST "/api/login/" {:username username :password password} + (fn [ok body] (rf/dispatch (if ok [:tl.events/login-ok body] + [:tl.events/login-error (:detail body)]))))) + +(defn logout! [] + (POST "/api/logout/" {} (fn [_ body] (rf/dispatch [:tl.events/set-auth body])))) + +(defn fetch-projects! [] + (-> (GET "/api/projects/") (.then #(rf/dispatch [:tl.events/set-projects %])))) + +(defn load-project! + "Detail → OTIO file → scene, then hand it all to ::project-ready in one shot." + [id] + (-> (GET (str "/api/projects/" id "/")) + (.then (fn [detail] + (-> (js/fetch (:otio detail)) + (.then #(.json %)) + (.then (fn [otio] + (-> (GET (str "/api/projects/" id "/scene/")) + (.then (fn [scene] + (rf/dispatch [:tl.events/project-ready + detail (->clj otio) scene]))))))))))) + +(defn put-scene! + "Persist a delta — {changed deleted} — for the current project (fire & forget)." + [id changed deleted] + (when id + (js/fetch (str base "/api/projects/" id "/scene/") + #js {:method "PUT" :credentials "include" + :headers #js {"Content-Type" "application/json"} + :body (js/JSON.stringify + (clj->js (cond-> {} + (seq changed) (assoc :changed changed) + (seq deleted) (assoc :deleted deleted))))}))) + +(defn create-project! [name otio clip] + (let [fd (js/FormData.)] + (.append fd "name" name) + (.append fd "otio" otio) + (.append fd "clip" clip) + (-> (js/fetch (str base "/api/projects/") #js {:method "POST" :credentials "include" :body fd}) + (.then (fn [r] (.then (.json r) #(vector (.-ok r) (->clj %))))) + (.then (fn [[ok body]] + (if ok + (rf/dispatch [:tl.events/project-created body]) + (rf/dispatch [:tl.events/project-create-error (:detail body)]))))))) diff --git a/tl/src/tl/core.cljs b/tl/src/tl/core.cljs index a5d94ff..5a51e8e 100644 --- a/tl/src/tl/core.cljs +++ b/tl/src/tl/core.cljs @@ -2,17 +2,23 @@ (:require [reagent.dom :as rdom] [re-frame.core :as re-frame] - [day8.re-frame.http-fx] ;; <-- add this [tl.events :as events] [tl.views :as views] - [tl.config :as config] - )) + [tl.config :as config])) (defn dev-setup [] (when config/debug? (println "dev mode"))) +(defn route! + "Map the URL hash to a page: #/p/ → editor, #/new → create, else list." + [] + (let [hash (.. js/window -location -hash)] + (if-let [[_ id] (re-find #"^#/p/(\d+)" hash)] + (re-frame/dispatch [::events/open-project (js/parseInt id 10)]) + (re-frame/dispatch (if (= hash "#/new") [::events/nav-create] [::events/nav-list]))))) + (defn ^:dev/after-load mount-root [] (re-frame/clear-subscription-cache!) (let [root-el (.getElementById js/document "app")] @@ -21,6 +27,8 @@ (defn init [] (re-frame/dispatch-sync [::events/initialize-db]) - (re-frame/dispatch [::events/load-otio]) + (re-frame/dispatch [::events/fetch-me]) + (.addEventListener js/window "hashchange" route!) + (route!) (dev-setup) (mount-root)) diff --git a/tl/src/tl/db.cljs b/tl/src/tl/db.cljs index 7576034..72dd51a 100644 --- a/tl/src/tl/db.cljs +++ b/tl/src/tl/db.cljs @@ -2,6 +2,11 @@ (def default-db {:load {:status :idle} ; :idle | :loading | :ready | :error + :page :list ; :list | :create | :editor + :auth {:authenticated false} ; from /api/me/ + :projects nil ; project list (when signed in) + :project nil ; current project detail {:id :name :clip :otio} + :create-error nil :fps (/ 24000 1001) ;; the whole scene graph (see tl.scene). Seeded from OTIO at load. diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index a15aa2d..90702ed 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -1,68 +1,67 @@ (ns tl.events (:require [re-frame.core :as rf] + [tl.api :as api] [tl.db :as db] [tl.otio :as otio] - [tl.scene :as scene] - [tl.storage :as storage] - [ajax.core :as ajax])) + [tl.scene :as scene])) (rf/reg-event-db ::initialize-db (fn [_ _] db/default-db)) -;; after a group-mutating event, persist the authored annotation layer -(def persist (rf/after (fn [db] (storage/save! (:scene db))))) +;; --- effects: talk to the backend ----------------------------------------- -;; --- load + seed ---------------------------------------------------------- +(rf/reg-fx :fetch-me (fn [_] (api/fetch-me!))) +(rf/reg-fx :fetch-projects (fn [_] (api/fetch-projects!))) +(rf/reg-fx :load-project (fn [id] (api/load-project! id))) +(rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted))) +(rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip))) +(rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash))) +(rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password))) +(rf/reg-fx :logout (fn [_] (api/logout!))) -(defn- demo-annotations - "A couple of seed annotations referencing real clips, so there's something to - render/expand. 'opening' takes the 1st and 3rd clips (skips the 2nd) to show - rearrange + gap removal when expanded." - [scene] - (let [clip-ids (->> (:groups scene) - (filter (fn [[_ g]] (= :clip (:type g)))) - (sort-by (fn [[_ g]] (-> g :marks first :start))) - (mapv first)) - whole (fn [mid clip] {:id mid :start {:ref clip :at 0} :end {:ref clip :at -1}}) - [a b c] (take 3 clip-ids)] - (cond-> scene - (and a b c) - (assoc-in [:groups :ann-opening] - {:type :annotation :parent :root :name "opening" :color "#4e8fc2" - :content "first and third shots, back to back — the middle is cut." - :marks [(whole :mo-a a) (whole :mo-c c)]}) - b - (assoc-in [:groups :ann-beat] - {:type :annotation :parent :root :name "the beat" :color "#c2624e" - :content "the second shot on its own." - :marks [(whole :mb-b b)]})))) +;; --- auth + routing ------------------------------------------------------- -(rf/reg-event-fx - ::load-otio - (fn [{:keys [db]} _] - {:db (assoc-in db [:load :status] :loading) - :http-xhrio {:method :get - :uri "/one_two_three.otio" - :response-format (ajax/json-response-format {:keywords? true}) - :on-success [::otio-loaded] - :on-failure [::otio-error]}})) +(rf/reg-event-fx ::set-auth + (fn [{:keys [db]} [_ me]] + (cond-> {:db (assoc db :auth me :login-error nil :projects nil)} + (:authenticated me) (assoc :fetch-projects true)))) ; load my projects +(rf/reg-event-fx ::fetch-me (fn [_ _] {:fetch-me true})) + +(rf/reg-event-fx ::login (fn [{:keys [db]} [_ u p]] + {:db (assoc db :login-error nil) :login {:username u :password p}})) +(rf/reg-event-db ::login-error (fn [db [_ msg]] (assoc db :login-error (or msg "login failed")))) +(rf/reg-event-fx ::logout (fn [_ _] {:logout true})) + +(rf/reg-event-fx ::nav-list + (fn [{:keys [db]} _] {:db (assoc db :page :list) :fetch-projects true})) +(rf/reg-event-db ::set-projects (fn [db [_ ps]] (assoc db :projects ps))) +(rf/reg-event-db ::nav-create (fn [db _] (assoc db :page :create :create-error nil))) + +(rf/reg-event-fx ::open-project + (fn [{:keys [db]} [_ id]] + {:db (-> db (assoc :page :editor) (assoc-in [:load :status] :loading)) + :load-project id})) (rf/reg-event-db - ::otio-loaded - (fn [db [_ raw]] - (let [parsed (otio/parse raw)] + ::project-ready + (fn [db [_ detail otio-raw scene-resp]] + (let [parsed (otio/parse otio-raw) + base (scene/from-otio parsed) + anns (get-in scene-resp [:scene :groups] {})] ; saved annotation layer (-> db - (assoc :fps (:fps parsed)) - (assoc :scene (let [scene (scene/from-otio parsed) - saved (storage/load)] - (if (nil? saved) - (demo-annotations scene) ; first run → seed demos - (update scene :groups merge saved)))) ; else restore authored layer + (assoc :project detail :page :editor) + (assoc :fps (or (:fps scene-resp) (:fps parsed))) + (assoc :scene (update base :groups merge anns)) + (assoc :view (:view db/default-db)) ; fresh stack/playheads per project (assoc-in [:load :status] :ready))))) -(rf/reg-event-db - ::otio-error - (fn [db [_ err]] (-> db (assoc-in [:load :status] :error) (assoc-in [:load :error] err)))) +;; --- create project ------------------------------------------------------- + +(rf/reg-event-fx ::create-project + (fn [{:keys [db]} [_ name otio clip]] + {:db (assoc db :create-error nil) :create-project {:name name :otio otio :clip clip}})) +(rf/reg-event-fx ::project-created (fn [_ [_ p]] {:goto (str "#/p/" (:id p))})) +(rf/reg-event-db ::project-create-error (fn [db [_ msg]] (assoc db :create-error (or msg "upload failed")))) ;; --- view ----------------------------------------------------------------- @@ -98,10 +97,20 @@ (rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) (assoc-in [:view :pt] :new)))) -(rf/reg-event-db ::put-group persist (fn [db [_ gid g]] (assoc-in db [:scene :groups gid] g))) -(rf/reg-event-db ::drop-group persist (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) (rf/reg-event-db ::draft-focus (fn [db [_ pt]] (assoc-in db [:view :pt] pt))) -(rf/reg-event-db ::delete-annotation persist (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) +;; cancelling a draft is local only; saving a real annotation / deleting one +;; pushes a delta to the backend (which merges + attributes it). +(rf/reg-event-db ::drop-group (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) +(rf/reg-event-fx ::put-group + (fn [{:keys [db]} [_ gid g]] + (cond-> {:db (assoc-in db [:scene :groups gid] g)} + (and (= :annotation (:type g)) (not (:draft g))) + (assoc :put-scene {:id (get-in db [:project :id]) + :changed {gid (dissoc g :draft :gid)}})))) +(rf/reg-event-fx ::delete-annotation + (fn [{:keys [db]} [_ gid]] + {:db (update-in db [:scene :groups] dissoc gid) + :put-scene {:id (get-in db [:project :id]) :deleted [gid]}})) ;; click a clip while authoring: first click sets a pending start, the second ;; completes the span as a run of single-clip marks. `frame` (mark time) is diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 2eb709b..422c3eb 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -5,6 +5,16 @@ (rf/reg-sub ::status (fn [db] (get-in db [:load :status]))) (rf/reg-sub ::playing? (fn [db] (get-in db [:view :playing?]))) (rf/reg-sub ::pt (fn [db] (get-in db [:view :pt]))) + +;; routing / projects / auth +(rf/reg-sub ::page (fn [db] (:page db))) +(rf/reg-sub ::projects (fn [db] (:projects db))) +(rf/reg-sub ::project (fn [db] (:project db))) +(rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip]))) +(rf/reg-sub ::create-error (fn [db] (:create-error db))) +(rf/reg-sub ::login-error (fn [db] (:login-error db))) +(rf/reg-sub ::authed? (fn [db] (boolean (get-in db [:auth :authenticated])))) +(rf/reg-sub ::username (fn [db] (get-in db [:auth :username]))) (rf/reg-sub ::fps (fn [db] (:fps db))) (rf/reg-sub ::scene (fn [db] (:scene db))) (rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 229fa2e..732316e 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -7,10 +7,6 @@ [tl.scene :as scene] [tl.events :as events])) -(def media-src - (str (.. js/window -location -protocol) "//" - (.. js/window -location -hostname) ":8281/challengers_480p.mp4")) - (defonce video-el (atom nil)) (defonce raf (atom nil)) (defonce scroll-el (atom nil)) @@ -78,7 +74,7 @@ (when @play (swap! play assoc :idx (seg-at segs local))))) ; keep playback in sync (defn video-monitor [] - [:video {:src media-src :controls true :preload "auto" + [:video {:src @(rf/subscribe [::subs/clip-url]) :controls true :preload "auto" :plays-inline true :webkit-playsinline "true" :ref (fn [n] (when n (reset! video-el n)))}]) @@ -212,16 +208,17 @@ (let [el (atom nil) open (r/atom nil)] (fn [] (let [anns @(rf/subscribe [::subs/annotations]) - scene @(rf/subscribe [::subs/scene]) - active @(rf/subscribe [::subs/active-annotation])] + active @(rf/subscribe [::subs/active-annotation]) + authed? @(rf/subscribe [::subs/authed?])] (r/after-render (fn [] (when-let [c @el] (when-let [node (and active (.querySelector c (str "#ann-" (name active))))] (set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3)))))))) [:div.commentary {:ref (fn [n] (reset! el n))} - [:div.commentary-head - [:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])} - "+ Add annotation"]] + (when authed? + [:div.commentary-head + [:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])} + "+ Add annotation"]]) (if (seq anns) (doall (for [a anns] @@ -233,13 +230,13 @@ [jump-control open a] [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} "⤢" (when (pos? (:nested a)) [:span.nest-badge (:nested a)])] - [:button.edit-btn {:title "Edit" - :on-click #(rf/dispatch [::events/edit-draft (:id a)])} - "✎"] - [:button.del-btn {:title "Delete" - :on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?")) - (rf/dispatch [::events/delete-annotation (:id a)]))} - "✕"]]] + (when authed? + [:button.edit-btn {:title "Edit" + :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]) + (when authed? + [:button.del-btn {:title "Delete" + :on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?")) + (rf/dispatch [::events/delete-annotation (:id a)]))} "✕"])]] (when (not-empty (:content a)) [:div.md (:content a)])])) [:div.ann-empty "No annotations here."])])))) @@ -335,19 +332,89 @@ (.addEventListener js/document "pointermove" move) (.addEventListener js/document "pointerup" up))) -(defn main-panel [] +(defn editor [] (let [status @(rf/subscribe [::subs/status]) - draft? (some? @(rf/subscribe [::subs/draft-group]))] + draft? (some? @(rf/subscribe [::subs/draft-group])) + proj @(rf/subscribe [::subs/project])] [:div.app [:div.top [:div.video-pane [video-monitor] [frame-readout]] [:div.annot-pane (if draft? [annotation-form] [commentary])]] [:div.divider-h {:on-pointer-down drag-top!}] [:div.timeline-pane + [:div.project-bar [:a.home-link {:href "#/"} "← projects"] [:span.project-name (:name proj)]] [breadcrumbs] [toolbar] [:div.timeline-scroll (case status :ready [timeline] - :error [:div {:style {:color "#e66" :padding 12}} "Failed to load OTIO"] - [:div {:style {:padding 12}} "Loading timeline…"])]]])) + :error [:div {:style {:color "#e66" :padding 12}} "Failed to load project"] + [:div {:style {:padding 12}} "Loading…"])]]])) + +;; --- project list / create (the landing pages) --------------------------- + +(defn- login-form [] + (let [u (r/atom "") p (r/atom "")] + (fn [] + (let [err @(rf/subscribe [::subs/login-error]) + submit #(when (and (seq @u) (seq @p)) (rf/dispatch [::events/login @u @p]))] + [:div.form.login + [:input.form-name {:placeholder "Username" :value @u :auto-focus true + :on-change #(reset! u (.. % -target -value)) + :on-key-down #(when (= "Enter" (.-key %)) (submit))}] + [:input.form-name {:placeholder "Password" :type "password" :value @p + :on-change #(reset! p (.. % -target -value)) + :on-key-down #(when (= "Enter" (.-key %)) (submit))}] + (when err [:div.err err]) + [:button.save {:on-click submit} "Log in"]])))) + +(defn list-page [] + (let [projects @(rf/subscribe [::subs/projects]) + authed? @(rf/subscribe [::subs/authed?]) + user @(rf/subscribe [::subs/username])] + [:div.home + [:div.home-head + [:h1 "lumet"] + (when authed? + [:span.muted "signed in as " user " · " + [:a {:href "#" :on-click #(do (.preventDefault %) (rf/dispatch [::events/logout]))} "log out"]])] + (if authed? + [:<> + [:a.add-btn {:href "#/new"} "+ New project"] + (if (seq projects) + [:ul.project-list + (for [p projects] + ^{:key (:id p)} + [:li [:a {:href (str "#/p/" (:id p))} (:name p)] + [:span.muted " · " (:owner__username p)]])] + [:p.muted "No projects yet — create one."])] + [:<> + [:p.muted "Log in to create and edit projects. You can still open a project by URL to view it."] + [login-form]])])) + +(defn create-page [] + (let [pname (r/atom "") otio (atom nil) clip (atom nil)] + (fn [] + (let [err @(rf/subscribe [::subs/create-error])] + [:div.home + [:div.home-head [:h1 "New project"] [:a {:href "#/"} "← back"]] + [:div.form + [:input.form-name {:placeholder "Project name" :value @pname + :on-change #(reset! pname (.. % -target -value))}] + [:label.file-row "OTIO timeline " + [:input {:type "file" :accept ".otio,.json,application/json" + :on-change #(reset! otio (-> % .-target .-files (aget 0)))}]] + [:label.file-row "Clip (video, ≤ 100 MB) " + [:input {:type "file" :accept "video/*" + :on-change #(reset! clip (-> % .-target .-files (aget 0)))}]] + (when err [:div.err err]) + [:div.form-actions + [:button.save {:disabled (or (str/blank? @pname) (not @otio) (not @clip)) + :on-click #(rf/dispatch [::events/create-project @pname @otio @clip])} "Create"] + [:a.cancel {:href "#/"} "Cancel"]]]])))) + +(defn main-panel [] + (case @(rf/subscribe [::subs/page]) + :create [create-page] + :editor [editor] + [list-page]))