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:
parent
94475da936
commit
20cd3cc6e3
9 changed files with 300 additions and 80 deletions
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
73
tl/src/tl/api.cljs
Normal 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)])))))))
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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])))
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue