Wire cljs frontend to the backend with session login

- frontend: hash routing (project list / create / editor), per-project init
  (load OTIO + scene + clip from the API), create form with otio + clip upload,
  annotation saves/deletes pushed as deltas, edit/add buttons gated on auth
- auth: own login form in cljs posting to session login/logout endpoints
  (httpOnly cookie — no JWT in JS); /api/me/ drives the UI's signed-in state
- backend: add /api/login/ and /api/logout/ (session auth)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-29 01:54:22 -04:00
parent 94475da936
commit 20cd3cc6e3
9 changed files with 300 additions and 80 deletions

View file

@ -4,6 +4,8 @@ from . import views
urlpatterns = [ urlpatterns = [
path("me/", views.me), path("me/", views.me),
path("login/", views.login_view),
path("logout/", views.logout_view),
path("projects/", views.projects), path("projects/", views.projects),
path("projects/<int:pk>/", views.project), path("projects/<int:pk>/", views.project),
path("projects/<int:pk>/scene/", views.scene), path("projects/<int:pk>/scene/", views.scene),

View file

@ -1,5 +1,6 @@
import json import json
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponseNotAllowed, JsonResponse from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404 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): def me(request):
"""GET /api/me/ — who the browser is logged in as (for the UI to gate edits).""" """GET /api/me/ — who the browser is logged in as (for the UI to gate edits)."""
u = request.user return JsonResponse(_who(request.user))
return JsonResponse({"authenticated": u.is_authenticated,
"username": u.get_username() if u.is_authenticated else None})
@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 @csrf_exempt

View file

@ -275,3 +275,25 @@ body { overflow: hidden; }
.divider-h { height: 14px; } /* bigger touch target (top vs timeline) */ .divider-h { height: 14px; } /* bigger touch target (top vs timeline) */
.slider input { width: 120px; } .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; }

73
tl/src/tl/api.cljs Normal file
View file

@ -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)])))))))

View file

@ -2,17 +2,23 @@
(:require (:require
[reagent.dom :as rdom] [reagent.dom :as rdom]
[re-frame.core :as re-frame] [re-frame.core :as re-frame]
[day8.re-frame.http-fx] ;; <-- add this
[tl.events :as events] [tl.events :as events]
[tl.views :as views] [tl.views :as views]
[tl.config :as config] [tl.config :as config]))
))
(defn dev-setup [] (defn dev-setup []
(when config/debug? (when config/debug?
(println "dev mode"))) (println "dev mode")))
(defn route!
"Map the URL hash to a page: #/p/<id> → 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 [] (defn ^:dev/after-load mount-root []
(re-frame/clear-subscription-cache!) (re-frame/clear-subscription-cache!)
(let [root-el (.getElementById js/document "app")] (let [root-el (.getElementById js/document "app")]
@ -21,6 +27,8 @@
(defn init [] (defn init []
(re-frame/dispatch-sync [::events/initialize-db]) (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) (dev-setup)
(mount-root)) (mount-root))

View file

@ -2,6 +2,11 @@
(def default-db (def default-db
{:load {:status :idle} ; :idle | :loading | :ready | :error {: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) :fps (/ 24000 1001)
;; the whole scene graph (see tl.scene). Seeded from OTIO at load. ;; the whole scene graph (see tl.scene). Seeded from OTIO at load.

View file

@ -1,68 +1,67 @@
(ns tl.events (ns tl.events
(:require (:require
[re-frame.core :as rf] [re-frame.core :as rf]
[tl.api :as api]
[tl.db :as db] [tl.db :as db]
[tl.otio :as otio] [tl.otio :as otio]
[tl.scene :as scene] [tl.scene :as scene]))
[tl.storage :as storage]
[ajax.core :as ajax]))
(rf/reg-event-db ::initialize-db (fn [_ _] db/default-db)) (rf/reg-event-db ::initialize-db (fn [_ _] db/default-db))
;; after a group-mutating event, persist the authored annotation layer ;; --- effects: talk to the backend -----------------------------------------
(def persist (rf/after (fn [db] (storage/save! (:scene db)))))
;; --- 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 ;; --- auth + routing -------------------------------------------------------
"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)]}))))
(rf/reg-event-fx (rf/reg-event-fx ::set-auth
::load-otio (fn [{:keys [db]} [_ me]]
(fn [{:keys [db]} _] (cond-> {:db (assoc db :auth me :login-error nil :projects nil)}
{:db (assoc-in db [:load :status] :loading) (:authenticated me) (assoc :fetch-projects true)))) ; load my projects
:http-xhrio {:method :get (rf/reg-event-fx ::fetch-me (fn [_ _] {:fetch-me true}))
:uri "/one_two_three.otio"
:response-format (ajax/json-response-format {:keywords? true}) (rf/reg-event-fx ::login (fn [{:keys [db]} [_ u p]]
:on-success [::otio-loaded] {:db (assoc db :login-error nil) :login {:username u :password p}}))
:on-failure [::otio-error]}})) (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 (rf/reg-event-db
::otio-loaded ::project-ready
(fn [db [_ raw]] (fn [db [_ detail otio-raw scene-resp]]
(let [parsed (otio/parse raw)] (let [parsed (otio/parse otio-raw)
base (scene/from-otio parsed)
anns (get-in scene-resp [:scene :groups] {})] ; saved annotation layer
(-> db (-> db
(assoc :fps (:fps parsed)) (assoc :project detail :page :editor)
(assoc :scene (let [scene (scene/from-otio parsed) (assoc :fps (or (:fps scene-resp) (:fps parsed)))
saved (storage/load)] (assoc :scene (update base :groups merge anns))
(if (nil? saved) (assoc :view (:view db/default-db)) ; fresh stack/playheads per project
(demo-annotations scene) ; first run → seed demos
(update scene :groups merge saved)))) ; else restore authored layer
(assoc-in [:load :status] :ready))))) (assoc-in [:load :status] :ready)))))
(rf/reg-event-db ;; --- create project -------------------------------------------------------
::otio-error
(fn [db [_ err]] (-> db (assoc-in [:load :status] :error) (assoc-in [:load :error] err)))) (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 ----------------------------------------------------------------- ;; --- view -----------------------------------------------------------------
@ -98,10 +97,20 @@
(rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) (rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit)
(assoc-in [:view :pt] :new)))) (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 ::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 ;; 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 ;; completes the span as a run of single-clip marks. `frame` (mark time) is

View file

@ -5,6 +5,16 @@
(rf/reg-sub ::status (fn [db] (get-in db [:load :status]))) (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 ::playing? (fn [db] (get-in db [:view :playing?])))
(rf/reg-sub ::pt (fn [db] (get-in db [:view :pt]))) (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 ::fps (fn [db] (:fps db)))
(rf/reg-sub ::scene (fn [db] (:scene db))) (rf/reg-sub ::scene (fn [db] (:scene db)))
(rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) (rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom])))

View file

@ -7,10 +7,6 @@
[tl.scene :as scene] [tl.scene :as scene]
[tl.events :as events])) [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 video-el (atom nil))
(defonce raf (atom nil)) (defonce raf (atom nil))
(defonce scroll-el (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 (when @play (swap! play assoc :idx (seg-at segs local))))) ; keep playback in sync
(defn video-monitor [] (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" :plays-inline true :webkit-playsinline "true"
:ref (fn [n] (when n (reset! video-el n)))}]) :ref (fn [n] (when n (reset! video-el n)))}])
@ -212,16 +208,17 @@
(let [el (atom nil) open (r/atom nil)] (let [el (atom nil) open (r/atom nil)]
(fn [] (fn []
(let [anns @(rf/subscribe [::subs/annotations]) (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 (r/after-render
(fn [] (when-let [c @el] (fn [] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))] (when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3)))))))) (set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
[:div.commentary {:ref (fn [n] (reset! el n))} [:div.commentary {:ref (fn [n] (reset! el n))}
[:div.commentary-head (when authed?
[:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])} [:div.commentary-head
"+ Add annotation"]] [:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])}
"+ Add annotation"]])
(if (seq anns) (if (seq anns)
(doall (doall
(for [a anns] (for [a anns]
@ -233,13 +230,13 @@
[jump-control open a] [jump-control open a]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
"⤢" (when (pos? (:nested a)) [:span.nest-badge (:nested a)])] "⤢" (when (pos? (:nested a)) [:span.nest-badge (:nested a)])]
[:button.edit-btn {:title "Edit" (when authed?
:on-click #(rf/dispatch [::events/edit-draft (:id a)])} [:button.edit-btn {:title "Edit"
"✎"] :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"])
[:button.del-btn {:title "Delete" (when authed?
:on-click #(when (js/confirm (str "Delete \"" (:name a) "\"?")) [:button.del-btn {:title "Delete"
(rf/dispatch [::events/delete-annotation (:id a)]))} :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)])])) (when (not-empty (:content a)) [:div.md (:content a)])]))
[:div.ann-empty "No annotations here."])])))) [:div.ann-empty "No annotations here."])]))))
@ -335,19 +332,89 @@
(.addEventListener js/document "pointermove" move) (.addEventListener js/document "pointermove" move)
(.addEventListener js/document "pointerup" up))) (.addEventListener js/document "pointerup" up)))
(defn main-panel [] (defn editor []
(let [status @(rf/subscribe [::subs/status]) (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.app
[:div.top [:div.top
[:div.video-pane [video-monitor] [frame-readout]] [:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane (if draft? [annotation-form] [commentary])]] [:div.annot-pane (if draft? [annotation-form] [commentary])]]
[:div.divider-h {:on-pointer-down drag-top!}] [:div.divider-h {:on-pointer-down drag-top!}]
[:div.timeline-pane [:div.timeline-pane
[:div.project-bar [:a.home-link {:href "#/"} "← projects"] [:span.project-name (:name proj)]]
[breadcrumbs] [breadcrumbs]
[toolbar] [toolbar]
[:div.timeline-scroll [:div.timeline-scroll
(case status (case status
:ready [timeline] :ready [timeline]
:error [:div {:style {:color "#e66" :padding 12}} "Failed to load OTIO"] :error [:div {:style {:color "#e66" :padding 12}} "Failed to load project"]
[:div {:style {:padding 12}} "Loading timeline…"])]]])) [: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]))