diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 5c824379..e7630bbb 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -6,9 +6,8 @@ on: jobs: test: - - name: Build and Run Mix Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 # Use a specific version for stability + name: Build OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} && Run Mix Test services: postgres: image: postgres:latest @@ -18,48 +17,55 @@ jobs: POSTGRES_USER: postgres ports: - 5432:5432 - # Set health checks to wait until postgres has started options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + strategy: + matrix: + otp: ['26.2'] + elixir: ['1.16.3'] steps: - - uses: actions/checkout@v2 - - name: Install dependecies for build - run: sudo apt-get install -y libncurses-dev libtinfo5 - - name: Set up Elixir - uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24 - with: - elixir-version: '1.15.7' # Define the elixir version [required] - otp-version: '25' # Define the OTP version [required] - - name: Restore dependencies cache - uses: actions/cache@v2 - with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - - name: Install dependencies - working-directory: ./ - run: mix deps.get - - name: Run tests - env: - # use localhost for the host here because we are running the job on the VM. - # If we were running the job on in a container this would be postgres - POSTGRES_HOST: localhost - POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} - working-directory: ./ - run: mix test + - name: Checkout code + uses: actions/checkout@v4 # Pin to a specific version for stability + + - name: Install dependencies for build + run: sudo apt-get update && sudo apt-get install -y libncurses-dev libtinfo5 + - name: Setup Elixir and OTP + uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }} + run: mix test deploy: needs: test name: Build & Deploy to Fly - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 # Use a specific version for stability steps: - - uses: actions/checkout@v2 - - uses: superfly/flyctl-actions@master + - name: Checkout code + uses: actions/checkout@v4 # Pin to a specific version for stability + + - name: Deploy to Fly.io + uses: superfly/flyctl-actions@master env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} FLY_APP: vyasa diff --git a/assets/css/app.css b/assets/css/app.css index 8f4956a6..bc948eb1 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -65,3 +65,54 @@ .emphasized-verse { @apply bg-brandAccentLight border-b-0 border-l-8 border-black p-4 pl-8 rounded-sm; } + +@font-face { + font-family: "et-book"; + src: url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot"); + src: url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot?#iefix") format("embedded-opentype"), url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff") format("woff"), url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf") format("truetype"), url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.svg#etbookromanosf") format("svg"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "et-book"; + src: url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot"); + src: url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot?#iefix") format("embedded-opentype"), url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff") format("woff"), url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf") format("truetype"), url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.svg#etbookromanosf") format("svg"); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "et-book"; + src: url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot"); + src: url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot?#iefix") format("embedded-opentype"), url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff") format("woff"), url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf") format("truetype"), url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.svg#etbookromanosf") format("svg"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "et-book-roman-old-style"; + src: url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot"); + src: url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot?#iefix") format("embedded-opentype"), url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff") format("woff"), url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf") format("truetype"), url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.svg#etbookromanosf") format("svg"); + font-weight: normal; + font-style: normal; + font-display: swap; +} +/* This file is for your main application CSS */ + +.hoverune, +.marginote { + float: right; + clear: right; + margin-right: -60%; + width: 50%; + margin-top: 0.3rem; + margin-bottom: 0; + font-size: 1.1rem; + line-height: 1.3; + vertical-align: baseline; + position: relative; +} diff --git a/assets/js/app.js b/assets/js/app.js index 0ef6eb47..dc1bd3b0 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,11 +36,20 @@ let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, }); + // Show progress bar on live navigation and form submits topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); +// Stream our server logs directly to our browser’s console +window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { + // enable server log streaming to client. + // disable with reloader.disableServerLogs() + reloader.enableServerLogs() + +}) + // connect if there are any LiveViews on the page liveSocket.connect(); diff --git a/assets/js/hooks/apply_modal.js b/assets/js/hooks/apply_modal.js new file mode 100644 index 00000000..ada9399b --- /dev/null +++ b/assets/js/hooks/apply_modal.js @@ -0,0 +1,15 @@ +const ApplyModal = () => { + const modal = document.querySelector('[data-selector="vyasa_modal_message"]') + + if (!modal) return + + const encodedReturnTo = encodeURIComponent(document.location.pathname) + + modal.querySelectorAll('a[data-phx-link="redirect"]').forEach(val => { + const url = new URL(val.href, document.location.origin) + url.searchParams.set('return_to', encodedReturnTo) + val.href = `${url.href}` + }) +} + +export default ApplyModal diff --git a/assets/js/hooks/audio_player.js b/assets/js/hooks/audio_player.js index 3ee4fc05..614cf56a 100644 --- a/assets/js/hooks/audio_player.js +++ b/assets/js/hooks/audio_player.js @@ -12,57 +12,73 @@ * general player-agnostic fashion. "Playback" and actual playback (i.e. audio or video playback) is decoupled, allowing * us the ability to reconcile bufferring states and other edge cases, mediated by the Media Bridge. * */ -let rand = (min, max) => Math.floor(Math.random() * (max - min) + min) -let isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0) +import { + seekTimeBridge, + playPauseBridge, + heartbeatBridge, + playbackMetaBridge, +} from "./mediaEventBridges"; let execJS = (selector, attr) => { - document.querySelectorAll(selector).forEach(el => liveSocket.execJS(el, el.getAttribute(attr))) -} + document + .querySelectorAll(selector) + .forEach((el) => liveSocket.execJS(el, el.getAttribute(attr))); +}; -import {seekTimeBridge, playPauseBridge, heartbeatBridge} from "./media_bridge.js" -import {formatDisplayTime, nowMs} from "../utils/time_utils.js" +import { formatDisplayTime, nowMs } from "../utils/time_utils.js"; AudioPlayer = { mounted() { this.isFollowMode = false; - this.playbackBeganAt = null - this.player = this.el.querySelector("audio") - - document.addEventListener("click", () => this.enableAudio()) - - this.player.addEventListener("loadedmetadata", e => this.handleMetadataLoad(e)) - - this.handleEvent("initSession", (sess) => this.initSession(sess)) + this.playbackBeganAt = null; + this.player = this.el.querySelector("audio"); + this.player.addEventListener("canplaythrough", (e) => + this.handlePlayableState(e), + ); /// Audio playback events: - this.handleEvent("stop", () => this.stop()) + this.handleEvent("stop", () => this.stop()); /// maps eventName to its deregisterer: this.eventBridgeDeregisterers = { - seekTime: seekTimeBridge.sub(payload => this.handleExternalSeekTime(payload)), - playPause: playPauseBridge.sub(payload => this.handleMediaPlayPause(payload)), - heartbeat: heartbeatBridge.sub(payload => this.echoHeartbeat(payload)), - } + seekTime: seekTimeBridge.sub((payload) => + this.handleExternalSeekTime(payload), + ), + playPause: playPauseBridge.sub((payload) => + this.handleMediaPlayPause(payload), + ), + heartbeat: heartbeatBridge.sub((payload) => this.echoHeartbeat(payload)), + playbackMeta: playbackMetaBridge.sub((playback) => + this.handlePlaybackMetaUpdate(playback), + ), + }; }, - /// Handlers: + /** + * Loads the audio onto the audio player and inits the MediaSession as soon as playback information is received. + * This allows the metadata and audio load to happen independently of users' + * actions that effect playback (e.g. play/pause) -- bufferring gets init a lot earlier + * as a result. + * */ + handlePlaybackMetaUpdate(playback) { + const { meta: playbackMeta } = playback; + const { file_path: filePath } = playbackMeta; + this.loadAudio(filePath); + this.initMediaSession(playback); + }, + /// Handlers for events received via the events bridge: handleMediaPlayPause(payload) { - console.log("[playPauseBridge::audio_player::playpause] payload:", payload) - const { - cmd, - playback, - } = payload + const { cmd, playback } = payload; if (cmd === "play") { - this.playMedia(playback) + this.playMedia(playback); } if (cmd === "pause") { - this.pause() + this.pause(); } }, handleExternalSeekTime(payload) { - console.log("[audio_player::seekTimeBridgeSub::seekTimeHandler] payload:", payload); - const {seekToMs: timeMs} = payload; - this.seekToMs(timeMs) + const { seekToMs: timeMs } = payload; + this.seekToMs(timeMs); }, /** * Returns information about the current playback. @@ -71,109 +87,97 @@ AudioPlayer = { * is documented to be in s. * */ readCurrentPlaybackInfo() { - const currentTimeMs = this.player.currentTime * 1000 - const durationMs = this.player.duration * 1000 + const currentTimeMs = this.player.currentTime * 1000; + const durationMs = this.player.duration * 1000; return { - isPlaying: !this.player.paused, - currentTimeMs, - durationMs, - } + isPlaying: !this.player.paused, + currentTimeMs, + durationMs, + }; }, echoHeartbeat(heartbeatPayload) { const shouldIgnoreSignal = heartbeatPayload.originator === "AudioPlayer"; - if(shouldIgnoreSignal) { - return + if (shouldIgnoreSignal) { + return; } - - console.log("[heartbeatBridge::audio_player] payload:", heartbeatPayload) const echoPayload = { originator: "AudioPlayer", - currentPlaybackInfo: this.readCurrentPlaybackInfo() - } - heartbeatBridge.pub(echoPayload) - }, - initSession(sess) { - localStorage.setItem("session", JSON.stringify(sess)) + currentPlaybackInfo: this.readCurrentPlaybackInfo(), + }; + heartbeatBridge.pub(echoPayload); }, - handleMetadataLoad(e) { - console.log("Loaded metadata!", { - duration: this.player.duration, - event: e, - }) + handlePlayableState(e) { + // TODO: consider if a handler is needed for the "canplaythrough" event + console.log( + "TRACE HandlePlayableState -- the audio can be played through completely now.", + e, + ); }, handlePlayPause() { - console.log("{play_pause event triggerred} player:", this.player) - if(this.player.paused){ - this.play() - } - }, - /** - * This "init" behaviour has been mimicked from live_beats. - * It is likely there to enable the audio player bufferring. - * */ - enableAudio() { - if(this.player.src){ - document.removeEventListener("click", this.enableAudio) - const hasNothingToPlay = this.player.readyState === 0; - if(hasNothingToPlay){ - this.player.play().catch(error => null) - this.player.pause() - } + if (this.player.paused) { + this.play(); } }, playMedia(playback) { - console.log("PlayMedia", playback) - const {meta: playbackMeta, "playing?": isPlaying, elapsed} = playback; - const { title, duration, file_path: filePath, artists } = playbackMeta; - const artist = artists ? artists[0] : "myArtist" // FIXME: this should be ready once seeding has been update to properly add in artist names - - const beginTime = nowMs() - elapsed - this.playbackBeganAt = beginTime - let currentSrc = this.player.src.split("?")[0] - - const isLoadedAndPaused = currentSrc === filePath && !isPlaying && this.player.paused; - if(isLoadedAndPaused){ - this.play({sync: true}) - } else if(currentSrc !== filePath) { - currentSrc = filePath - this.player.src = currentSrc - this.play({sync: true}) - } + const { meta: playbackMeta, "playing?": isPlaying, elapsed } = playback; + const { file_path: filePath } = playbackMeta; + this.updateMediaSession(playback); - // TODO: supply necessary info for media sessions api here... - const isMediaSessionApiSupported = "mediaSession" in navigator; - if(isMediaSessionApiSupported){ - navigator.mediaSession.metadata = new MediaMetadata({artist, title}) + const beginTime = nowMs() - elapsed; + this.playbackBeganAt = beginTime; + let currentSrc = this.getCurrentSrc(); + const isLoadedAndPaused = + currentSrc === filePath && !isPlaying && this.player.paused; + if (isLoadedAndPaused) { + this.play({ sync: true }); + } else if (currentSrc !== filePath) { + this.loadAudio(filePath); + this.play({ sync: true }); } }, - play(opts = {}){ - console.log("Triggered playback, check params", { - player: this.player, - opts, - }) + loadAudio(src) { + const isSrcAlreadyLoaded = src === this.getCurrentSrc(); + if (isSrcAlreadyLoaded) { + return; + } + this.player.src = src; + }, + getCurrentSrc() { + if (!this?.player?.src) { + return null; + } + // since the html5 player's src value is typically a url string, it will have url encodings e.g. ContentType. + // Therefore, we strip away these urlencodes: + const src = this.player.src.split("?")[0]; - let {sync} = opts + return src; + }, + play(opts = {}) { + let { sync } = opts; - this.player.play().then(() => { - if(sync) { - const currentTimeMs = nowMs() - this.playbackBeganAt; + this.player.play().then( + () => { + if (sync) { + const currentTimeMs = nowMs() - this.playbackBeganAt; - this.player.currentTime = currentTimeMs / 1000; - const formattedCurrentTime = formatDisplayTime(currentTimeMs); - } - }, error => { - if(error.name === "NotAllowedError"){ - execJS("#enable-audio", "data-js-show") - } - }) + this.player.currentTime = currentTimeMs / 1000; + const formattedCurrentTime = formatDisplayTime(currentTimeMs); + } + }, + (error) => { + if (error.name === "NotAllowedError") { + execJS("#enable-audio", "data-js-show"); + } + }, + ); }, - pause(){ - this.player.pause() + pause() { + this.player.pause(); }, - stop(){ - this.player.pause() - this.player.currentTime = 0 + stop() { + this.player.pause(); + this.player.currentTime = 0; }, /** * The exposed api for html5 audio player is such that currentTime is a number value @@ -183,15 +187,71 @@ AudioPlayer = { * This preserves as much precision as possible. * */ seekToMs(timeMs) { - const beginTime = nowMs() - timeMs + const beginTime = nowMs() - timeMs; this.playbackBeganAt = beginTime; this.player.currentTime = timeMs / 1000; if (!this.player.paused) { - this.player.play() // force a play event if is not paused + this.player.play(); // force a play event if is not paused + } + }, + /** + * At the point of init, we register some action handlers and update the media session's metadata + * */ + initMediaSession(playback) { + // TODO: register action handlers + this.registerActionHandlers(playback); + this.updateMediaSession(playback); + }, + registerActionHandlers(playback) { + const isSupported = "mediaSession" in navigator; + if (!isSupported) { + return; + } + + const session = navigator.mediaSession; + const playPauseEvents = ["play", "pause"]; + playPauseEvents.forEach((e) => + session.setActionHandler(e, (e) => this.dispatchPlayPauseToServer(e)), + ); + }, + dispatchPlayPauseToServer(_e) { + this.pushEvent("play_pause"); + }, + updateMediaSession(playback) { + const isSupported = "mediaSession" in navigator; + if (!isSupported) { + return; } + const payload = this.createMediaMetadataPayload(playback); + navigator.mediaSession.metadata = new MediaMetadata(payload); }, -} + createMediaMetadataPayload(playback) { + if (!playback) { + return {}; + } + const { meta } = playback; + const sessionMetadata = navigator?.mediaSession?.metadata; + const oldMetadata = sessionMetadata + ? { + title: sessionMetadata.title, + artist: sessionMetadata.artist, + album: sessionMetadata.album, + artwork: sessionMetadata.artwork, + } + : {}; + const artist = meta?.artists + ? meta.artists.join(", ") + : (sessionMetadata.artist ?? "Unknown artist"); + const metadata = { + ...oldMetadata, + ...meta, + artist, + }; + + return metadata; + }, +}; export default AudioPlayer; diff --git a/assets/js/hooks/floater.js b/assets/js/hooks/floater.js index 33bb2e68..37f5efd9 100644 --- a/assets/js/hooks/floater.js +++ b/assets/js/hooks/floater.js @@ -2,6 +2,12 @@ * Ideally generic hook for floating logic. */ import {isMobileDevice} from "../utils/uncategorised_utils.js"; +import { + computePosition, + autoPlacement, + shift, + offset, +} from "floating-ui.dom.umd.min"; Floater = { mounted() { @@ -48,13 +54,6 @@ Floater = { return } - const { - computePosition, - autoPlacement, - shift, - offset, - } = window.FloatingUIDOM; - computePosition(reference, floater, { placement: 'right', // NOTE: order of middleware matters. diff --git a/assets/js/hooks/hoverune.js b/assets/js/hooks/hoverune.js new file mode 100644 index 00000000..47905347 --- /dev/null +++ b/assets/js/hooks/hoverune.js @@ -0,0 +1,92 @@ +import { computePosition, offset, inline, autoUpdate } from "floating-ui.dom.umd.min"; + +const findParent = (el, attr, stopper) => { + // need identifier for marginoted content + if (el && el.localName == "body") return null + if (el && el.getAttribute(attr) == stopper) return el + if (!el) return null + return findParent(el.parentElement, attr, stopper) +} + +function floatHoveRune({clientX, clientY}) { + const selection = window.getSelection() + var getSelectRect = selection.getRangeAt(0).getBoundingClientRect() + const virtualEl = { + getBoundingClientRect() { + return getSelectRect + }, + contextElement: document.querySelector('#verses'), + }; + const hoverune = document.getElementById("hoverune"); + + computePosition(virtualEl, hoverune, {placement: 'top-end', middleware: [inline(getSelectRect.x, getSelectRect.y), offset(5)]}).then(({x, y}) => { + // Position the floating element relative to the click + hoverune.classList.remove("hidden") + Object.assign(hoverune.style, { + left: `${getSelectRect.x}px`, + top: `${y}px`, + }) + }); + + // computePosition(virtualEl, hoverune, {placement: 'top-end', middleware: [inline(getSelectRect.x, getSelectRect.y), offset(5)]}).then(({x, y}) => { + // hoverune.classList.remove("hidden") + // Object.assign(hoverune.style, { + // left: `${getSelectRect.x}px`, + // top: `${y}px`, + // }); + // }) +} + +const findHook = el => findParent(el, "phx-hook", "HoveRune") +const findMarginote = el => findParent(el, "phx-hook", "MargiNote") +const findNode = el => el && el.getAttribute('node') +const forgeBinding = (el, attrs) => attrs.reduce((acc, attr) => { + acc[attr] = el.getAttribute(attr) + return acc +}, {}) +// const marginoteParent = el => findParent(el, "data-marginote", "parent") + +export default HoveRune = { + mounted() { + const t = this.el + const targetEvents = ['pointerdown', 'pointerup'] + targetEvents.forEach(e => window.addEventListener(e, ({ target }) => { + var selection = window.getSelection() + if (!selection || selection.rangeCount <= 0) { + return + } + + var getSelectRect = selection.getRangeAt(0).getBoundingClientRect(); + const getSelectText = selection.toString() + //const validElem = findHook(target) + // const isMarginote = findMarginote(target) + const isNode = findNode(target) + + if (isNode) { + binding = forgeBinding(target, ["node", "node_id", "field", "verse_id"]) + binding["selection"] = getSelectText + + this.pushEvent("bindHoveRune", {"binding": binding}) + + console.log(binding) + + + computePosition(target, hoverune, {placement: 'top-end', middleware: [inline(getSelectRect.x, getSelectRect.y), offset(5)]}).then(({x, y}) => { + hoverune.classList.remove("hidden") + Object.assign(hoverune.style, { + left: `${getSelectRect.x}px`, + top: `${y}px`, + }); + }) + + } + else { + hoverune.classList.add("hidden") + } + })) + + }, + + updated() { + } +} diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index 25afeba0..2e312973 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -7,8 +7,11 @@ import MiniPlayer from "./mini_player.js"; import MediaBridge from "./media_bridge.js"; import AudioPlayer from "./audio_player.js"; import ProgressBar from "./progress_bar.js"; -import Floater from "./floater.js" - +import Floater from "./floater.js"; +import ApplyModal from "./apply_modal.js"; +import MargiNote from "./marginote.js"; +import HoveRune from "./hoverune.js"; +import Scrolling from "./scrolling.js"; let Hooks = { ShareQuoteButton, @@ -19,6 +22,10 @@ let Hooks = { AudioPlayer, ProgressBar, Floater, + ApplyModal, + MargiNote, + HoveRune, + Scrolling, }; export default Hooks; diff --git a/assets/js/hooks/marginote.js b/assets/js/hooks/marginote.js new file mode 100644 index 00000000..6e0e619c --- /dev/null +++ b/assets/js/hooks/marginote.js @@ -0,0 +1,35 @@ +import { computePosition, offset, autoPlacement } from "floating-ui.dom.umd.min"; + +const findParents = (el, parents = []) => { + if (el && el.localName == "body") return parents + if (el && el.getAttribute("phx-hook") == "Marginote") return findParents(el.parentElement, parents.concat([el])) + if (!el) return parents + return findParents(el.parentElement, parents) +} + +export default MargiNote = { + func: 0, + mounted() { + const t = this.el + const marginoteParent = document.querySelector("[data-marginote='parent']") + t.addEventListener("click", ev => { + const selection = window.getSelection().toString() + + if (selection !== "") return + ev.stopPropagation() + + if (!marginoteParent) return + + const parents = findParents(t.parentElement, []).map(f => f.id.replace("marginote-id-", "")) + + + this.pushEvent("show-quoted-comment", {id: t.id, parents}, () => { + computePosition(this.el, marginoteParent, { + middleware: [offset(10), autoPlacement()], + }).then(({ x, y}) => { + this.pushEvent("adjust-marginote", {top: `${y}px`, left: `${x}px`}) + }) + }) + }) + }, +} diff --git a/assets/js/hooks/media/bridged.js b/assets/js/hooks/mediaEventBridges/bridged.js similarity index 100% rename from assets/js/hooks/media/bridged.js rename to assets/js/hooks/mediaEventBridges/bridged.js diff --git a/assets/js/hooks/mediaEventBridges/index.js b/assets/js/hooks/mediaEventBridges/index.js new file mode 100644 index 00000000..97b886f9 --- /dev/null +++ b/assets/js/hooks/mediaEventBridges/index.js @@ -0,0 +1,17 @@ +/** + * This file contains definitions for custom event bridges and keeps + * the exporting of these clean. + * */ + +import { bridged } from "./bridged.js"; + +export const seekTimeBridge = bridged("seekTime"); +export const playPauseBridge = bridged("playPause"); +export const heartbeatBridge = bridged("heartbeat"); +/** + * The playbackMetaBridge is the channel through which playback-metadata related + * messages are passed. + * An example would be when a voice first gets loaded/registered and we can + * update the mediasessions api even if the user has not started the actual playback. + * */ +export const playbackMetaBridge = bridged("playback"); diff --git a/assets/js/hooks/media_bridge.js b/assets/js/hooks/media_bridge.js index 757ceb74..5582d035 100644 --- a/assets/js/hooks/media_bridge.js +++ b/assets/js/hooks/media_bridge.js @@ -6,221 +6,252 @@ * * Event-handling is done using custom bridged events as a proxy. * */ -import { bridged } from "./media/bridged.js"; -import { formatDisplayTime } from "../utils/time_utils.js" - -// TODO: consider switching to a map of bridges to support other key events -export const seekTimeBridge = bridged("seekTime"); -export const playPauseBridge = bridged("playPause") -export const heartbeatBridge = bridged("heartbeat") +import { formatDisplayTime } from "../utils/time_utils.js"; +import { + seekTimeBridge, + playPauseBridge, + heartbeatBridge, + playbackMetaBridge, +} from "./mediaEventBridges"; MediaBridge = { mounted() { - this.currentTime = this.el.querySelector("#player-time") + this.currentTime = this.el.querySelector("#player-time"); this.isFollowMode = false; - this.duration = this.el.querySelector("#player-duration") - this.progress = this.el.querySelector("#player-progress") - - const emphasizedChapterPreamble = this.emphasizeChapterPreamble() - this.emphasizedDomNode = { - prev: null, - current: emphasizedChapterPreamble, - } - this.el.addEventListener("update_display_value", e => this.handleUpdateDisplayValue(e)) - this.handleEvent("media_bridge:registerEventsTimeline", params => this.registerEventsTimeline(params)) - + this.duration = this.el.querySelector("#player-duration"); + this.progress = this.el.querySelector("#player-progress"); + this.emphasizedDomNode = this.initEmphasizedNode(); + this.el.addEventListener("update_display_value", (e) => + this.handleUpdateDisplayValue(e), + ); + this.handleEvent("media_bridge:registerEventsTimeline", (params) => + this.registerEventsTimeline(params), + ); + this.handleEvent("media_bridge:registerPlayback", (params) => + this.registerPlaybackInfo(params), + ); + this.handleEvent("initSession", (sess) => this.initSession(sess)); // pub: external action // this callback pubs to others - this.handleEvent("media_bridge:seekTime", (seekTimePayload) => { - const { - originator, - } = seekTimePayload; - console.assert(originator === "MediaBridge", "This event may only originate from the MediaBridge server.") - - seekTimeBridge.pub(seekTimePayload) - }) - - this.handleEvent("media_bridge:play_pause", (playPausePayload) => { - const { - originator, - } = playPausePayload; - console.assert(originator === "MediaBridge", "This event may only originate from the MediaBridge server.") - - console.log("media_bridge:play_pause", playPausePayload) - playPauseBridge.pub(playPausePayload) - }) - - this.handleEvent("toggleFollowMode", () => this.toggleFollowMode()) // TODO: candidate for shifting to media_bridge.js? - + this.handleEvent("media_bridge:play_pause", (payload) => + this.receivePlayPauseFromServer(payload), + ); + this.handleEvent("media_bridge:seekTime", (payload) => + this.receiveSeekTimeFromServer(payload), + ); + this.handleEvent("toggleFollowMode", () => this.toggleFollowMode()); // TODO: candidate for shifting to media_bridge.js? // this callback: is internal to media_bridge // internal action this.eventBridgeDeregisterers = { - seekTime: seekTimeBridge.sub(payload => this.handleSeekTime(payload)), - playPause: playPauseBridge.sub(payload => this.handlePlayPause(payload)), - heartbeat: heartbeatBridge.sub(payload => this.handleHeartbeat(payload)), - } + seekTime: seekTimeBridge.sub((payload) => this.handleSeekTime(payload)), + playPause: playPauseBridge.sub((payload) => + this.handlePlayPause(payload), + ), + heartbeat: heartbeatBridge.sub((payload) => + this.handleHeartbeat(payload), + ), + }; + }, + /** + * Saves current session id + * */ + initSession(sess) { + localStorage.setItem("session", JSON.stringify(sess)); }, toggleFollowMode() { this.isFollowMode = !this.isFollowMode; }, handleHeartbeat(payload) { - console.log("[MediaBridge::handleHeartbeat]", payload) const shouldIgnoreSignal = payload.originator === "MediaBridge"; - if(shouldIgnoreSignal) { + if (shouldIgnoreSignal) { return; } // originator is expected to be audio player - console.assert(payload.originator === "AudioPlayer", "MediaBridge only expects heartbeat acks to come from AudioPlayer"); - console.log(">>> progress update, payload:", {payload, eventsTimeline: this.eventsTimeline}) - const { - currentTimeMs, - durationMs, - } = payload.currentPlaybackInfo || {}; + console.assert( + payload.originator === "AudioPlayer", + "MediaBridge only expects heartbeat acks to come from AudioPlayer", + ); + const { currentTimeMs, durationMs } = payload.currentPlaybackInfo || {}; - this.updateTimeDisplay(currentTimeMs, durationMs) - this.emphasizeActiveEvent(currentTimeMs, this.eventsTimeline) + this.updateTimeDisplay(currentTimeMs, durationMs); + this.emphasizeActiveEvent(currentTimeMs, this.eventsTimeline); + }, + /** + * Returns the map that gets used to init the preamble as the emphasized node. + * This happens when the hook mounts. + * */ + initEmphasizedNode() { + const emphasizedChapterPreamble = this.emphasizeChapterPreamble(); + return { + prev: null, + current: emphasizedChapterPreamble, + }; }, /** * Emphasizes then returns the node reference to the chapter's preamble. * This is so that @ mount, at least the chapter preamble shall be emphasized * */ emphasizeChapterPreamble() { - const preambleNode = document.querySelector("#chapter-preamble") + const preambleNode = document.querySelector("#chapter-preamble"); if (!preambleNode) { - console.log("[EMPHASIZE], no preamble node found") - return null + console.warn("[EMPHASIZE], no preamble node found"); + return null; } - preambleNode.classList.add("emphasized-verse") + preambleNode.classList.add("emphasized-verse"); - console.log("[EMPHASIZE], preamble node:", preambleNode) - - return preambleNode + return preambleNode; }, emphasizeActiveEvent(currentTimeMs, events) { if (!events) { - console.log("No events found") return; } - const activeEvent = events.find(event => currentTimeMs >= event.origin && - currentTimeMs < (event.origin + event.duration)) + const activeEvent = events.find( + (event) => + currentTimeMs >= event.origin && + currentTimeMs < event.origin + event.duration, + ); if (!activeEvent) { - console.log("No active event found @ time = ", currentTime) return; } - const { - verse_id: verseId - } = activeEvent; + const { verse_id: verseId } = activeEvent; if (!verseId) { - return + return; } - const { - prev: prevDomNode, - current: currDomNode, - } = this.emphasizedDomNode; // @ this point it wouldn't have been updated yet + const { prev: prevDomNode, current: currDomNode } = this.emphasizedDomNode; // @ this point it wouldn't have been updated yet - const updatedEmphasizedDomNode = {} - if(currDomNode) { - currDomNode.classList.remove("emphasized-verse") + const updatedEmphasizedDomNode = {}; + if (currDomNode) { + currDomNode.classList.remove("emphasized-verse"); updatedEmphasizedDomNode.prev = currDomNode; } - const targetDomId = `verse-${verseId}` - const targetNode = document.getElementById(targetDomId) - targetNode.classList.add("emphasized-verse") + const targetDomId = `verse-${verseId}`; + const targetNode = document.getElementById(targetDomId); + targetNode.classList.add("emphasized-verse"); updatedEmphasizedDomNode.current = targetNode; - if(this.isFollowMode) { - targetNode.focus() + if (this.isFollowMode) { + targetNode.focus(); targetNode.scrollIntoView({ - behavior: 'smooth', - block: 'start', + behavior: "smooth", + block: "start", }); } this.emphasizedDomNode = updatedEmphasizedDomNode; }, startHeartbeat() { - const heartbeatInterval = 100 // 10fps, comfortable for human eye - console.log("Starting heartbeat!") + const heartbeatInterval = 100; // 10fps, comfortable for human eye const heartbeatPayload = { originator: "MediaBridge", - } - const heartbeatTimer = setInterval(() => heartbeatBridge.pub(heartbeatPayload), heartbeatInterval) - console.log("Started Heartbeat with:", {heartbeatTimer, heartbeatPayload, heartbeatInterval}) - - this.heartbeatTimer = heartbeatTimer + }; + const heartbeatTimer = setInterval( + () => heartbeatBridge.pub(heartbeatPayload), + heartbeatInterval, + ); + this.heartbeatTimer = heartbeatTimer; }, killHeartbeat() { - console.log("Killing heartbeat!", {heartbeatTimer: this.heartbeatTimer}) - clearInterval(this.heartbeatTimer) + clearInterval(this.heartbeatTimer); }, - updateTimeDisplay(timeMs, durationMs=null) { + updateTimeDisplay(timeMs, durationMs = null) { const currentTimeDisplay = formatDisplayTime(timeMs); - this.currentTime.innerText = currentTimeDisplay - console.log("Updated time display to", currentTimeDisplay); + this.currentTime.innerText = currentTimeDisplay; - if(durationMs) { - const durationDisplay = formatDisplayTime(durationMs) - this.duration.innerText = durationDisplay + if (durationMs) { + const durationDisplay = formatDisplayTime(durationMs); + this.duration.innerText = durationDisplay; } }, seekToMs(originator, timeMs) { - console.log("media_bridge.js::seekToMs", {timeMs, originator}) - const knownOriginators = ["ProgressBar", "MediaBridge"] // temp-list, will be removed + const knownOriginators = ["ProgressBar", "MediaBridge"]; // temp-list, will be removed if (!knownOriginators.includes(originator)) { - console.warn(`originator ${originator} is not a known originator. Is not one of ${knownOriginators}.`) + console.warn( + `originator ${originator} is not a known originator. Is not one of ${knownOriginators}.`, + ); } this.updateTimeDisplay(timeMs); }, handleUpdateDisplayValue(e) { - const { - detail, - } = e - const [key, val, extraKey] = detail?.payload + const { detail } = e; + const [key, val, extraKey] = detail?.payload; if (extraKey === "innerText") { this[key][extraKey] = val; } if (extraKey === "style.width") { - this[key].style.width = val + this[key].style.width = val; } }, registerEventsTimeline(params) { - console.log("Register Events Timeline", params); - this.eventsTimeline = params.voice_events + const { voice_events } = params; + this.eventsTimeline = voice_events; }, - handleSeekTime(payload) { - console.log("[media_bridge::seekTimeBridgeSub::seekTimeHandler] payload", payload); - const { - seekToMs: timeMs, - originator, - } = payload; - this.seekToMs(originator, timeMs) + /** + * First registers the playback information about a playable medium (e.g. voice). + * The intent of this is to separate out tasks for interfacing with things like MediaSessions api + * from interfacing with the concrete players (e.g. play pause event on the audio player). + * */ + registerPlaybackInfo(params) { + const { playback } = params; + playbackMetaBridge.pub(playback); + }, + /** + * Receives event pushed from the server, then pubs through the + * */ + receivePlayPauseFromServer(playPausePayload) { + const { originator } = playPausePayload; + console.assert( + originator === "MediaBridge", + "This event may only originate from the MediaBridge server.", + ); + + this.updateHeartbeatFromPlayPause(playPausePayload); + playPauseBridge.pub(playPausePayload); }, handlePlayPause(payload) { - console.log("[playPauseBridge::media_bridge:playpause] payload:", payload) - const { - cmd, - playback, - originator, - } = payload - - // TODO: implement handler for actions emitted via interaction with youtube player - console.log(">> [media_bridge.js::playPauseBridge], received a signal", payload) + const { originator } = payload; + + const shouldIgnoreSignal = originator === "MediaBridge"; + if (shouldIgnoreSignal) { + return; + } + + this.updateHeartbeatFromPlayPause(payload); + }, + /** + * Updates the MediaBridgeHook's internal heartbeat timer depending on the command + * given (play or pause). + * + * NOTE: This doesn't guard for the originator of the command. + * */ + updateHeartbeatFromPlayPause(payload) { + const { cmd } = payload; if (cmd === "play") { - this.startHeartbeat() + this.startHeartbeat(); } if (cmd === "pause") { - this.killHeartbeat() + this.killHeartbeat(); } - } - -} + }, + receiveSeekTimeFromServer(seekTimePayload) { + const { originator } = seekTimePayload; + console.assert( + originator === "MediaBridge", + "This event may only originate from the MediaBridge server.", + ); + seekTimeBridge.pub(seekTimePayload); + }, + handleSeekTime(payload) { + const { seekToMs: timeMs, originator } = payload; + this.seekToMs(originator, timeMs); + }, +}; export default MediaBridge; diff --git a/assets/js/hooks/mini_player.js b/assets/js/hooks/mini_player.js index f92d5982..c8c68ed0 100644 --- a/assets/js/hooks/mini_player.js +++ b/assets/js/hooks/mini_player.js @@ -34,17 +34,16 @@ const bindListenersToEventsOnEl = (el, listeners, events) => { }); }; +import { autoUpdate} from "floating-ui.dom.umd.min"; + const autoUpdatePosition = () => { const { verseContainer, youtubePlayerContainer, } = getRelevantElements(); - const { - autoUpdate - } = window.FloatingUIDOM - window.FloatingUIDOM.cleanupAutoPositioning = autoUpdate( + autoUpdate( verseContainer, youtubePlayerContainer, alignMiniPlayer, @@ -71,15 +70,15 @@ const getRelevantElements = () => { return {button, youtubePlayerContainer, verseContainer} } -const alignMiniPlayer = () => { - const {verseContainer, youtubePlayerContainer} = getRelevantElements(); - - const { +import { computePosition, autoPlacement, shift, offset, - } = window.FloatingUIDOM; + } from "floating-ui.dom.umd.min"; + +const alignMiniPlayer = () => { + const {verseContainer, youtubePlayerContainer} = getRelevantElements(); computePosition(verseContainer, youtubePlayerContainer, { diff --git a/assets/js/hooks/progress_bar.js b/assets/js/hooks/progress_bar.js index ad1e05a0..68db4f4f 100644 --- a/assets/js/hooks/progress_bar.js +++ b/assets/js/hooks/progress_bar.js @@ -2,66 +2,57 @@ * Progress Bar hooks intended to sync playback related actions * */ -import {seekTimeBridge, heartbeatBridge} from "./media_bridge.js" +import { seekTimeBridge, heartbeatBridge } from "./mediaEventBridges"; ProgressBar = { mounted() { this.el.addEventListener("click", (e) => { e.preventDefault(); - this.handleProgressBarClick(e) + this.handleProgressBarClick(e); }); - const heartbeatDeregisterer = heartbeatBridge.sub(payload => this.handleHeartbeat(payload)) - const seekTimeDeregisterer = seekTimeBridge.sub(payload => this.handleExternalSeekTime(payload)) + const heartbeatDeregisterer = heartbeatBridge.sub((payload) => + this.handleHeartbeat(payload), + ); + const seekTimeDeregisterer = seekTimeBridge.sub((payload) => + this.handleExternalSeekTime(payload), + ); this.eventBridgeDeregisterers = { seekTime: seekTimeDeregisterer, heartbeat: heartbeatDeregisterer, - } + }; }, handleExternalSeekTime(payload) { - console.log("[progress_bar::seekTimeBridgeSub::seekTimeHandler] this:", {payload}); - const { - seekToMs: timeMs, - originator, - } = payload; + const { seekToMs: timeMs, originator } = payload; const shouldIgnoreSignal = originator === "ProgressBar"; if (shouldIgnoreSignal) { - console.info("Ignoring signal for seekTime", payload) - return; } - const maxTime = this.el.dataset?.max || this.maxTime - if(!maxTime) { - console.warn("Max time not available in element's state or dataset, ignoring progress bar update.") - return + const maxTime = this.el.dataset?.max || this.maxTime; + if (!maxTime) { + console.warn( + "Max time not available in element's state or dataset, ignoring progress bar update.", + ); + return; } - const playbackPercentage = (timeMs / maxTime) - const progressStyleWidth = `${(playbackPercentage * 100)}%` - console.log("[DEBUG]", { - maxTime, - playbackPercentage, - }) - this.setProgressBarWidth(progressStyleWidth) + const playbackPercentage = timeMs / maxTime; + const progressStyleWidth = `${playbackPercentage * 100}%`; + this.setProgressBarWidth(progressStyleWidth); }, handleHeartbeat(payload) { - console.log("[ProgressBar::handleHeartbeat]", payload) const shouldIgnoreSignal = payload.originator === "MediaBridge"; - if(shouldIgnoreSignal) { + if (shouldIgnoreSignal) { return; } - const { - currentTimeMs, - durationMs, - } = payload.currentPlaybackInfo || {}; + const { currentTimeMs, durationMs } = payload.currentPlaybackInfo || {}; - const playbackPercentage = (currentTimeMs / durationMs) - const progressStyleWidth = `${(playbackPercentage * 100)}%` - console.log("handleHeartbeat, set progress bar width", progressStyleWidth) - this.setProgressBarWidth(progressStyleWidth) + const playbackPercentage = currentTimeMs / durationMs; + const progressStyleWidth = `${playbackPercentage * 100}%`; + this.setProgressBarWidth(progressStyleWidth); }, /* // The progress bar is measured in milliseconds, @@ -86,54 +77,43 @@ ProgressBar = { // }) */ handleProgressBarClick(e) { - const { max: maxTime } = this.el.dataset + const { max: maxTime } = this.el.dataset; if (!maxTime) { - console.log("unable to seek position, payload is incorrect") - return + return; } - const containerNode = document.getElementById("player-progress-container") - const maxOffset = containerNode.offsetWidth + const containerNode = document.getElementById("player-progress-container"); + const maxOffset = containerNode.offsetWidth; this.maxTime = maxTime; this.maxOffset = maxOffset; const currXOffset = e.offsetX; - const maxPlaybackMs = Number(maxTime) - const playbackPercentage = (currXOffset / maxOffset) - const positionMs = maxPlaybackMs * playbackPercentage - const progressStyleWidth = `${(playbackPercentage * 100)}%` - this.setProgressBarWidth(progressStyleWidth) + const maxPlaybackMs = Number(maxTime); + const playbackPercentage = currXOffset / maxOffset; + const positionMs = maxPlaybackMs * playbackPercentage; + const progressStyleWidth = `${playbackPercentage * 100}%`; + this.setProgressBarWidth(progressStyleWidth); // Optimistic update this.el.value = positionMs; - console.log("seek attempt @ positionMs:", { - checkThis: this, - elem: this.el, - event: e, - maxOffset, - currXOffset, - playbackPercentage, - maxPlaybackMs, - positionMs, - }) - // pubs & dispatches this position const seekTimePayload = { seekToMs: positionMs, originator: "ProgressBar", - } - seekTimeBridge.dispatch(this, seekTimePayload, "#media-player-container") + }; + seekTimeBridge.dispatch(this, seekTimePayload, "#media-player-container"); return; }, - setProgressBarWidth(progressStyleWidth, selector="#player-progress") { - console.log("setting progress bar width:", progressStyleWidth) - const progressBarNode = document.querySelector(selector) - console.assert(!!progressBarNode, "progress bar node must always be present in the dom.") + setProgressBarWidth(progressStyleWidth, selector = "#player-progress") { + const progressBarNode = document.querySelector(selector); + console.assert( + !!progressBarNode, + "progress bar node must always be present in the dom.", + ); progressBarNode.style.width = progressStyleWidth; - } + }, }; - export default ProgressBar; diff --git a/assets/js/hooks/scrolling.js b/assets/js/hooks/scrolling.js new file mode 100644 index 00000000..68af5873 --- /dev/null +++ b/assets/js/hooks/scrolling.js @@ -0,0 +1,11 @@ +Scrolling = { + mounted() { + console.log("SCROLLING MOUNTED"); + this.handleEvent("scroll-to-top", this.handleScrollToTop); + }, + handleScrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); + }, +}; + +export default Scrolling; diff --git a/assets/js/hooks/share_quote.js b/assets/js/hooks/share_quote.js index 3e6436a5..0194bda7 100644 --- a/assets/js/hooks/share_quote.js +++ b/assets/js/hooks/share_quote.js @@ -3,13 +3,13 @@ */ import { getSharer } from "./web_share.js"; - ShareQuoteButton = { mounted() { initTooltip(); let callback = () => console.log("share quote!"); - if ("share" in navigator) { // uses webshare api: + if ("share" in navigator) { + // uses webshare api: callback = () => { const shareTitle = this.el.getAttribute("data-share-title"); const shareUrl = window.location.href; @@ -21,9 +21,9 @@ ShareQuoteButton = { window.shareUrl = sharer; window.shareUrl(shareUrl); }; - } else if ("clipboard" in navigator) { // copies to clipboard: + } else if ("clipboard" in navigator) { + // copies to clipboard: callback = () => { - console.log(">> see me:", {"floating ui:": window}) const { chapter_number: chapterNum, verse_number: verseNum, @@ -31,7 +31,7 @@ ShareQuoteButton = { text, } = JSON.parse(this.el.getAttribute("data-verse")); - const content = `[Gita Chapter ${chapterNum} Verse ${verseNum}] \n${text}\n${transliteration}\nRead more at ${document.URL}` + const content = `[Gita Chapter ${chapterNum} Verse ${verseNum}] \n${text}\n${transliteration}\nRead more at ${document.URL}`; navigator.clipboard.writeText(content); }; } @@ -41,66 +41,50 @@ ShareQuoteButton = { }; function showTooltip() { - const {tooltip} = getButtonAndTooltip(); - tooltip.style.display = 'block'; + const { tooltip } = getButtonAndTooltip(); + tooltip.style.display = "block"; alignTooltip(); } function hideTooltip() { - const {tooltip} = getButtonAndTooltip(); - tooltip.style.display = ''; + const { tooltip } = getButtonAndTooltip(); + tooltip.style.display = ""; } const initTooltip = () => { - const {button} = getButtonAndTooltip(); + const { button } = getButtonAndTooltip(); [ - ['mouseenter', showTooltip], - ['mouseleave', hideTooltip], - ['focus', showTooltip], - ['blur', hideTooltip], + ["mouseenter", showTooltip], + ["mouseleave", hideTooltip], + ["focus", showTooltip], + ["blur", hideTooltip], ].forEach(([event, listener]) => { button.addEventListener(event, listener); }); -} +}; const getButtonAndTooltip = () => { - const id = "ShareQuoteButton" - const button = document.getElementById(id) - const tooltip = document.getElementById(`tooltip-${id}`) - return {button, tooltip} - -} + const id = "ShareQuoteButton"; + const button = document.getElementById(id); + const tooltip = document.getElementById(`tooltip-${id}`); + return { button, tooltip }; +}; +import { computePosition, flip, shift, offset } from "floating-ui.dom.umd.min"; const alignTooltip = () => { - const {button, tooltip} = getButtonAndTooltip() - console.log(">>> found?", { - button, - tooltip, - }) - const { - computePosition, - flip, - shift, - offset, - } = window.FloatingUIDOM; - + const { button, tooltip } = getButtonAndTooltip(); computePosition(button, tooltip, { - placement: 'right', + placement: "right", // NOTE: order of middleware matters. - middleware: [offset(6), flip(), shift({padding: 16})], - }).then(({x, y}) => { - console.log(">>> computed new position!", { - x, - y - }) + middleware: [offset(6), flip(), shift({ padding: 16 })], + }).then(({ x, y }) => { Object.assign(tooltip.style, { left: `${x}px`, top: `${y}px`, }); }); -} - +}; export default ShareQuoteButton; diff --git a/assets/js/hooks/youtube_player.js b/assets/js/hooks/youtube_player.js index 20cdcb79..4b55453f 100644 --- a/assets/js/hooks/youtube_player.js +++ b/assets/js/hooks/youtube_player.js @@ -2,61 +2,57 @@ * Follower * Validates if required parameters exist. * */ -import {seekTimeBridge, playPauseBridge} from "./media_bridge.js" -import { isMobileDevice } from "../utils/uncategorised_utils.js" +import { seekTimeBridge, playPauseBridge } from "./mediaEventBridges"; + +import { isMobileDevice } from "../utils/uncategorised_utils.js"; const isYouTubeFnCallable = (dataset) => { - const {functionName, eventName} = dataset; - const areFnAndEventNamesProvided = functionName && eventName - if(!areFnAndEventNamesProvided) { + const { functionName, eventName } = dataset; + const areFnAndEventNamesProvided = functionName && eventName; + if (!areFnAndEventNamesProvided) { console.warn("Need to provide both valid function name and event name"); - return false + return false; } - const supportedEvents = ["click", "mouseover"] + const supportedEvents = ["click", "mouseover"]; if (!supportedEvents.includes(eventName)) { - console.warn(`${eventName} is not a supported event. Supported events include ${supportedEvents}.`); - return false + console.warn( + `${eventName} is not a supported event. Supported events include ${supportedEvents}.`, + ); + return false; } - const supportedFunctionNames = Object.keys(youtubePlayerCallbacks) + const supportedFunctionNames = Object.keys(youtubePlayerCallbacks); if (!supportedFunctionNames.includes(functionName)) { - console.warn(`${functionName} is not a supported youtube function. Supported functions include ${supportedFunctionNames}.`); + console.warn( + `${functionName} is not a supported youtube function. Supported functions include ${supportedFunctionNames}.`, + ); return false; } - - return true -} + return true; +}; // NOTE: the player interface can be found @ https://developers.google.com/youtube/iframe_api_reference#Functions const youtubePlayerCallbacks = { - seekTo: function(options) { - const {targetTimeStamp, player} = options; - const target = Number(targetTimeStamp) - console.log("seeking to: ", target) - return player.seekTo(target) + seekTo: function (options) { + const { targetTimeStamp, player } = options; + const target = Number(targetTimeStamp); + return player.seekTo(target); }, - loadVideoById: function(options) { - const { - targetTimeStamp: startSeconds, - videoId, - player, - } = options; - console.log(`Loading video with id ${videoId} at t=${startSeconds}s`) - player.loadVideoById({videoId, startSeconds}) + loadVideoById: function (options) { + const { targetTimeStamp: startSeconds, videoId, player } = options; + player.loadVideoById({ videoId, startSeconds }); }, - getAllStats: function(options) { // this is a custom function - const { - hook, - player, - } = options; + getAllStats: function (options) { + // this is a custom function + const { hook, player } = options; const stats = { duration: player.getDuration(), videoUrl: player.getVideoUrl(), currentTime: player.getCurrentTime(), - } - hook.pushEventTo("#statsHover", "reportVideoStatus", stats) - } -} + }; + hook.pushEventTo("#statsHover", "reportVideoStatus", stats); + }, +}; /** * Contains client-side logic for the youtube iframe embeded player. @@ -65,68 +61,48 @@ const youtubePlayerCallbacks = { */ export const RenderYouTubePlayer = { mounted() { - const { - videoId, - playerConfig: serialisedPlayerConfig, - } = this.el.dataset; - console.log("Check dataset", this.el.dataset) + const { videoId, playerConfig: serialisedPlayerConfig } = this.el.dataset; - const playerConfig = JSON.parse(serialisedPlayerConfig) - const updatedConfig = overrideConfigForMobile(playerConfig) - injectIframeDownloadScript() - injectYoutubeInitialiserScript(videoId, updatedConfig) + const playerConfig = JSON.parse(serialisedPlayerConfig); + const updatedConfig = overrideConfigForMobile(playerConfig); + injectIframeDownloadScript(); + injectYoutubeInitialiserScript(videoId, updatedConfig); // TODO: capture youtube player events (play state changes and pub to the same event bridges, so as to control overall playback) this.eventBridgeDeregisterers = { seekTime: seekTimeBridge.sub((payload) => this.handleSeekTime(payload)), - playPause: playPauseBridge.sub(payload => this.handlePlayPause(payload)), - } - this.handleEvent("stop", () => this.stop()) + playPause: playPauseBridge.sub((payload) => + this.handlePlayPause(payload), + ), + }; + this.handleEvent("stop", () => this.stop()); }, handlePlayPause(payload) { - console.log("[playPauseBridge::audio_player::playpause] payload:", payload) - const { - cmd, - playback, - } = payload + const { cmd, playback } = payload; if (cmd === "play") { - this.playMedia(playback) + this.playMedia(playback); } if (cmd === "pause") { - this.pauseMedia() + this.pauseMedia(); } }, handleSeekTime(payload) { - console.log("[youtube_player::seekTimeBridgeSub::seekTimeHandler] check params:", {payload} ); - let {seekToMs: timeMs} = payload; - this.seekToMs(timeMs) + let { seekToMs: timeMs } = payload; + this.seekToMs(timeMs); }, - playMedia(playback) { - console.log("youtube player playMedia triggerred", playback) - const {meta: playbackMeta, "playing?": isPlaying, elapsed} = playback; - const { title, duration, file_path: filePath, artists } = playbackMeta; - const artist = artists ? artists[0] : "myArtist" // FIXME: this should be ready once seeding has been update to properly add in artist names - + playMedia(_playback) { // TODO: consider if the elapsed ms should be used here for better sync(?) - window.youtubePlayer.playVideo() + window.youtubePlayer.playVideo(); }, pauseMedia() { - console.log("youtube player pauseMedia triggerred") - window.youtubePlayer.pauseVideo() - }, - stop() { - console.log("youtube player stop triggerred") + window.youtubePlayer.pauseVideo(); }, + stop() {}, seekToMs(timeMs) { const timeS = timeMs / 1000; - console.log("youtube player seekto triggerred", { - timeS, - player: window.youtubePlayer - }) - window.youtubePlayer.seekTo(timeS); - } + }, }; /** @@ -134,11 +110,11 @@ export const RenderYouTubePlayer = { * so that it gets fired before any other script. * */ const injectIframeDownloadScript = () => { - const tag = document.createElement("script"); - tag.src = "https://www.youtube.com/iframe_api"; - const firstScriptTag = document.getElementsByTagName("script")?.[0]; - firstScriptTag && firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); -} + const tag = document.createElement("script"); + tag.src = "https://www.youtube.com/iframe_api"; + const firstScriptTag = document.getElementsByTagName("script")?.[0]; + firstScriptTag && firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); +}; /** * Injects a script that contains initialisation logic for the Youtube Player object. @@ -152,13 +128,13 @@ const injectYoutubeInitialiserScript = (videoId, playerConfig) => { videoId: videoId, events: { onReady: onPlayerReady, - } - } - window.youtubePlayer = new YT.Player("player", assimilatedConfig) - } + }, + }; + window.youtubePlayer = new YT.Player("player", assimilatedConfig); + }; window.callbackOnPlayerReady = (event) => { event.target.playVideo(); - } + }; const stringifiedScript = ` function onYouTubeIframeAPIReady() { @@ -166,40 +142,43 @@ const injectYoutubeInitialiserScript = (videoId, playerConfig) => { } function onPlayerReady(event) { window.callbackOnPlayerReady(event) - }` + }`; const functionCode = document.createTextNode(stringifiedScript); - iframeInitialiserScript.appendChild(functionCode) -} + iframeInitialiserScript.appendChild(functionCode); +}; export const TriggerYouTubeFunction = { mounted() { if (!isYouTubeFnCallable(this.el.dataset)) { - console.warn("YouTube function can not be triggerred.") - return + console.warn("YouTube function can not be triggerred."); + return; } - const {functionName, eventName} = this.el.dataset - const callback = youtubePlayerCallbacks[functionName] - const getOptions = () => ({hook: this,...this.el.dataset, player: window.youtubePlayer}) - this.el.addEventListener(eventName, () => callback(getOptions())) - } -} + const { functionName, eventName } = this.el.dataset; + const callback = youtubePlayerCallbacks[functionName]; + const getOptions = () => ({ + hook: this, + ...this.el.dataset, + player: window.youtubePlayer, + }); + this.el.addEventListener(eventName, () => callback(getOptions())); + }, +}; /// FIXME: this is a temp fix, that overrides the dimensions if it's a mobile. // there has to be a better, more generic way of handling this. // Alternatively, if we can reverse engineer a custom PIP mode (with resize and all that), then // we won't need to fix this. const overrideConfigForMobile = (playerConfig) => { - let overridedConfig = {...playerConfig} - if(isMobileDevice()) { - overridedConfig["height"] = "150", - overridedConfig["width"] = "200", - console.log("[iframe] updating the player config:", { - before: playerConfig, - after: overridedConfig, - - }) + let overridedConfig = { ...playerConfig }; + if (isMobileDevice()) { + (overridedConfig["height"] = "150"), + (overridedConfig["width"] = "200"), + console.log("[iframe] updating the player config:", { + before: playerConfig, + after: overridedConfig, + }); } return overridedConfig; -} +}; diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 83e22aa7..8e66d50d 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -1,36 +1,37 @@ // See the Tailwind configuration guide for advanced usage // https://tailwindcss.com/docs/configuration -const plugin = require("tailwindcss/plugin") -const fs = require("fs") -const path = require("path") +const plugin = require("tailwindcss/plugin"); +const fs = require("fs"); +const path = require("path"); module.exports = { content: [ "./js/**/*.js", "../lib/vyasa_web.ex", - "../lib/vyasa_web/**/*.*ex" + "../lib/vyasa/display/user_mode.ex", + "../lib/vyasa_web/**/*.*ex", ], theme: { extend: { fontFamily: { - 'dn': ['"Gotu"', 'sans-serif'], - 'tl': ['"Tamil Font"', 'sans-serif'], + dn: ['"Gotu"', "sans-serif"], + tl: ['"Tamil Font"', "sans-serif"], }, colors: { - primary: 'var(--color-primary)', - primaryAccent: 'var(--color-primary-accent)', - secondary: 'var(--color-secondary)', - primaryBackground: 'var(--color-primary-background)', - secondary: 'var(--color-secondary)', - brand: 'var(--color-brand)', - brandLight: 'var(--color-brand-light)', - brandExtraLight: 'var(--color-brand-extra-light)', - brandAccent: 'var(--color-brand-accent)', - brandAccentLight: 'var(--color-brand-accent-light)', - brandDark: 'var(--color-brand-dark)', - brandExtraDark: 'var(--color-brand-extra-dark)', - } + primary: "var(--color-primary)", + primaryAccent: "var(--color-primary-accent)", + secondary: "var(--color-secondary)", + primaryBackground: "var(--color-primary-background)", + secondary: "var(--color-secondary)", + brand: "var(--color-brand)", + brandLight: "var(--color-brand-light)", + brandExtraLight: "var(--color-brand-extra-light)", + brandAccent: "var(--color-brand-accent)", + brandAccentLight: "var(--color-brand-accent-light)", + brandDark: "var(--color-brand-dark)", + brandExtraDark: "var(--color-brand-extra-dark)", + }, }, }, plugins: [ @@ -41,44 +42,70 @@ module.exports = { // //
// - plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), - plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), - plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), - plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), + plugin(({ addVariant }) => + addVariant("phx-no-feedback", [ + ".phx-no-feedback&", + ".phx-no-feedback &", + ]), + ), + plugin(({ addVariant }) => + addVariant("phx-click-loading", [ + ".phx-click-loading&", + ".phx-click-loading &", + ]), + ), + plugin(({ addVariant }) => + addVariant("phx-submit-loading", [ + ".phx-submit-loading&", + ".phx-submit-loading &", + ]), + ), + plugin(({ addVariant }) => + addVariant("phx-change-loading", [ + ".phx-change-loading&", + ".phx-change-loading &", + ]), + ), // Embeds Heroicons (https://heroicons.com) into your app.css bundle // See your `CoreComponents.icon/1` for more information. // - plugin(function({matchComponents, theme}) { - let iconsDir = path.join(__dirname, "./vendor/heroicons/optimized") - let values = {} + plugin(function ({ matchComponents, theme }) { + let iconsDir = path.join(__dirname, "./vendor/heroicons/optimized"); + let values = {}; let icons = [ ["", "/24/outline"], ["-solid", "/24/solid"], - ["-mini", "/20/solid"] - ] + ["-mini", "/20/solid"], + ]; icons.forEach(([suffix, dir]) => { - fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { - let name = path.basename(file, ".svg") + suffix - values[name] = {name, fullPath: path.join(iconsDir, dir, file)} - }) - }) - matchComponents({ - "hero": ({name, fullPath}) => { - let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") - return { - [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, - "-webkit-mask": `var(--hero-${name})`, - "mask": `var(--hero-${name})`, - "mask-repeat": "no-repeat", - "background-color": "currentColor", - "vertical-align": "middle", - "display": "inline-block", - "width": theme("spacing.5"), - "height": theme("spacing.5") - } - } - }, {values}) - }) - ] -} + fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { + let name = path.basename(file, ".svg") + suffix; + values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; + }); + }); + matchComponents( + { + hero: ({ name, fullPath }) => { + let content = fs + .readFileSync(fullPath) + .toString() + .replace(/\r?\n|\r/g, ""); + return { + [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, + "-webkit-mask": `var(--hero-${name})`, + mask: `var(--hero-${name})`, + "mask-repeat": "no-repeat", + "background-color": "currentColor", + "vertical-align": "middle", + display: "inline-block", + width: theme("spacing.5"), + height: theme("spacing.5"), + }; + }, + }, + { values }, + ); + }), + ], +}; diff --git a/assets/vendor/@floating-ui/core.js b/assets/vendor/@floating-ui/core.js new file mode 100644 index 00000000..815359c2 --- /dev/null +++ b/assets/vendor/@floating-ui/core.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUICore={})}(this,(function(t){"use strict";const e=["top","right","bottom","left"],n=["start","end"],i=e.reduce(((t,e)=>t.concat(e,e+"-"+n[0],e+"-"+n[1])),[]),o=Math.min,r=Math.max,a={left:"right",right:"left",bottom:"top",top:"bottom"},l={start:"end",end:"start"};function s(t,e,n){return r(t,o(e,n))}function f(t,e){return"function"==typeof t?t(e):t}function c(t){return t.split("-")[0]}function u(t){return t.split("-")[1]}function m(t){return"x"===t?"y":"x"}function d(t){return"y"===t?"height":"width"}function g(t){return["top","bottom"].includes(c(t))?"y":"x"}function p(t){return m(g(t))}function h(t,e,n){void 0===n&&(n=!1);const i=u(t),o=p(t),r=d(o);let a="x"===o?i===(n?"end":"start")?"right":"left":"start"===i?"bottom":"top";return e.reference[r]>e.floating[r]&&(a=w(a)),[a,w(a)]}function y(t){return t.replace(/start|end/g,(t=>l[t]))}function w(t){return t.replace(/left|right|bottom|top/g,(t=>a[t]))}function x(t){return"number"!=typeof t?function(t){return{top:0,right:0,bottom:0,left:0,...t}}(t):{top:t,right:t,bottom:t,left:t}}function v(t){return{...t,top:t.y,left:t.x,right:t.x+t.width,bottom:t.y+t.height}}function b(t,e,n){let{reference:i,floating:o}=t;const r=g(e),a=p(e),l=d(a),s=c(e),f="y"===r,m=i.x+i.width/2-o.width/2,h=i.y+i.height/2-o.height/2,y=i[l]/2-o[l]/2;let w;switch(s){case"top":w={x:m,y:i.y-o.height};break;case"bottom":w={x:m,y:i.y+i.height};break;case"right":w={x:i.x+i.width,y:h};break;case"left":w={x:i.x-o.width,y:h};break;default:w={x:i.x,y:i.y}}switch(u(e)){case"start":w[a]-=y*(n&&f?-1:1);break;case"end":w[a]+=y*(n&&f?-1:1)}return w}async function A(t,e){var n;void 0===e&&(e={});const{x:i,y:o,platform:r,rects:a,elements:l,strategy:s}=t,{boundary:c="clippingAncestors",rootBoundary:u="viewport",elementContext:m="floating",altBoundary:d=!1,padding:g=0}=f(e,t),p=x(g),h=l[d?"floating"===m?"reference":"floating":m],y=v(await r.getClippingRect({element:null==(n=await(null==r.isElement?void 0:r.isElement(h)))||n?h:h.contextElement||await(null==r.getDocumentElement?void 0:r.getDocumentElement(l.floating)),boundary:c,rootBoundary:u,strategy:s})),w="floating"===m?{...a.floating,x:i,y:o}:a.reference,b=await(null==r.getOffsetParent?void 0:r.getOffsetParent(l.floating)),A=await(null==r.isElement?void 0:r.isElement(b))&&await(null==r.getScale?void 0:r.getScale(b))||{x:1,y:1},R=v(r.convertOffsetParentRelativeRectToViewportRelativeRect?await r.convertOffsetParentRelativeRectToViewportRelativeRect({elements:l,rect:w,offsetParent:b,strategy:s}):w);return{top:(y.top-R.top+p.top)/A.y,bottom:(R.bottom-y.bottom+p.bottom)/A.y,left:(y.left-R.left+p.left)/A.x,right:(R.right-y.right+p.right)/A.x}}function R(t,e){return{top:t.top-e.height,right:t.right-e.width,bottom:t.bottom-e.height,left:t.left-e.width}}function P(t){return e.some((e=>t[e]>=0))}function T(t){const e=o(...t.map((t=>t.left))),n=o(...t.map((t=>t.top)));return{x:e,y:n,width:r(...t.map((t=>t.right)))-e,height:r(...t.map((t=>t.bottom)))-n}}t.arrow=t=>({name:"arrow",options:t,async fn(e){const{x:n,y:i,placement:r,rects:a,platform:l,elements:c,middlewareData:m}=e,{element:g,padding:h=0}=f(t,e)||{};if(null==g)return{};const y=x(h),w={x:n,y:i},v=p(r),b=d(v),A=await l.getDimensions(g),R="y"===v,P=R?"top":"left",T=R?"bottom":"right",D=R?"clientHeight":"clientWidth",O=a.reference[b]+a.reference[v]-w[v]-a.floating[b],E=w[v]-a.reference[v],L=await(null==l.getOffsetParent?void 0:l.getOffsetParent(g));let k=L?L[D]:0;k&&await(null==l.isElement?void 0:l.isElement(L))||(k=c.floating[D]||a.floating[b]);const C=O/2-E/2,B=k/2-A[b]/2-1,H=o(y[P],B),S=o(y[T],B),F=H,j=k-A[b]-S,z=k/2-A[b]/2+C,M=s(F,z,j),V=!m.arrow&&null!=u(r)&&z!==M&&a.reference[b]/2-(zu(e)===t)),...n.filter((e=>u(e)!==t))]:n.filter((t=>c(t)===t))).filter((n=>!t||u(n)===t||!!e&&y(n)!==n))}(p||null,x,w):w,R=await A(e,v),P=(null==(n=l.autoPlacement)?void 0:n.index)||0,T=b[P];if(null==T)return{};const D=h(T,a,await(null==m.isRTL?void 0:m.isRTL(d.floating)));if(s!==T)return{reset:{placement:b[0]}};const O=[R[c(T)],R[D[0]],R[D[1]]],E=[...(null==(o=l.autoPlacement)?void 0:o.overflows)||[],{placement:T,overflows:O}],L=b[P+1];if(L)return{data:{index:P+1,overflows:E},reset:{placement:L}};const k=E.map((t=>{const e=u(t.placement);return[t.placement,e&&g?t.overflows.slice(0,2).reduce(((t,e)=>t+e),0):t.overflows[0],t.overflows]})).sort(((t,e)=>t[1]-e[1])),C=(null==(r=k.filter((t=>t[2].slice(0,u(t[0])?2:3).every((t=>t<=0))))[0])?void 0:r[0])||k[0][0];return C!==s?{data:{index:P+1,overflows:E},reset:{placement:C}}:{}}}},t.computePosition=async(t,e,n)=>{const{placement:i="bottom",strategy:o="absolute",middleware:r=[],platform:a}=n,l=r.filter(Boolean),s=await(null==a.isRTL?void 0:a.isRTL(e));let f=await a.getElementRects({reference:t,floating:e,strategy:o}),{x:c,y:u}=b(f,i,s),m=i,d={},g=0;for(let n=0;nt+"-"+o)),e&&(r=r.concat(r.map(y)))),r}(l,b,v,D));const E=[l,...O],L=await A(e,R),k=[];let C=(null==(i=r.flip)?void 0:i.overflows)||[];if(d&&k.push(L[P]),g){const t=h(o,a,D);k.push(L[t[0]],L[t[1]])}if(C=[...C,{placement:o,overflows:k}],!k.every((t=>t<=0))){var B,H;const t=((null==(B=r.flip)?void 0:B.index)||0)+1,e=E[t];if(e)return{data:{index:t,overflows:C},reset:{placement:e}};let n=null==(H=C.filter((t=>t.overflows[0]<=0)).sort(((t,e)=>t.overflows[1]-e.overflows[1]))[0])?void 0:H.placement;if(!n)switch(x){case"bestFit":{var S;const t=null==(S=C.map((t=>[t.placement,t.overflows.filter((t=>t>0)).reduce(((t,e)=>t+e),0)])).sort(((t,e)=>t[1]-e[1]))[0])?void 0:S[0];t&&(n=t);break}case"initialPlacement":n=l}if(o!==n)return{reset:{placement:n}}}return{}}}},t.hide=function(t){return void 0===t&&(t={}),{name:"hide",options:t,async fn(e){const{rects:n}=e,{strategy:i="referenceHidden",...o}=f(t,e);switch(i){case"referenceHidden":{const t=R(await A(e,{...o,elementContext:"reference"}),n.reference);return{data:{referenceHiddenOffsets:t,referenceHidden:P(t)}}}case"escaped":{const t=R(await A(e,{...o,altBoundary:!0}),n.floating);return{data:{escapedOffsets:t,escaped:P(t)}}}default:return{}}}}},t.inline=function(t){return void 0===t&&(t={}),{name:"inline",options:t,async fn(e){const{placement:n,elements:i,rects:a,platform:l,strategy:s}=e,{padding:u=2,x:m,y:d}=f(t,e),p=Array.from(await(null==l.getClientRects?void 0:l.getClientRects(i.reference))||[]),h=function(t){const e=t.slice().sort(((t,e)=>t.y-e.y)),n=[];let i=null;for(let t=0;ti.height/2?n.push([o]):n[n.length-1].push(o),i=o}return n.map((t=>v(T(t))))}(p),y=v(T(p)),w=x(u);const b=await l.getElementRects({reference:{getBoundingClientRect:function(){if(2===h.length&&h[0].left>h[1].right&&null!=m&&null!=d)return h.find((t=>m>t.left-w.left&&mt.top-w.top&&d=2){if("y"===g(n)){const t=h[0],e=h[h.length-1],i="top"===c(n),o=t.top,r=e.bottom,a=i?t.left:e.left,l=i?t.right:e.right;return{top:o,bottom:r,left:a,right:l,width:l-a,height:r-o,x:a,y:o}}const t="left"===c(n),e=r(...h.map((t=>t.right))),i=o(...h.map((t=>t.left))),a=h.filter((n=>t?n.left===i:n.right===e)),l=a[0].top,s=a[a.length-1].bottom;return{top:l,bottom:s,left:i,right:e,width:e-i,height:s-l,x:i,y:l}}return y}},floating:i.floating,strategy:s});return a.reference.x!==b.reference.x||a.reference.y!==b.reference.y||a.reference.width!==b.reference.width||a.reference.height!==b.reference.height?{reset:{rects:b}}:{}}}},t.limitShift=function(t){return void 0===t&&(t={}),{options:t,fn(e){const{x:n,y:i,placement:o,rects:r,middlewareData:a}=e,{offset:l=0,mainAxis:s=!0,crossAxis:u=!0}=f(t,e),d={x:n,y:i},p=g(o),h=m(p);let y=d[h],w=d[p];const x=f(l,e),v="number"==typeof x?{mainAxis:x,crossAxis:0}:{mainAxis:0,crossAxis:0,...x};if(s){const t="y"===h?"height":"width",e=r.reference[h]-r.floating[t]+v.mainAxis,n=r.reference[h]+r.reference[t]-v.mainAxis;yn&&(y=n)}if(u){var b,A;const t="y"===h?"width":"height",e=["top","left"].includes(c(o)),n=r.reference[p]-r.floating[t]+(e&&(null==(b=a.offset)?void 0:b[p])||0)+(e?0:v.crossAxis),i=r.reference[p]+r.reference[t]+(e?0:(null==(A=a.offset)?void 0:A[p])||0)-(e?v.crossAxis:0);wi&&(w=i)}return{[h]:y,[p]:w}}}},t.offset=function(t){return void 0===t&&(t=0),{name:"offset",options:t,async fn(e){var n,i;const{x:o,y:r,placement:a,middlewareData:l}=e,s=await async function(t,e){const{placement:n,platform:i,elements:o}=t,r=await(null==i.isRTL?void 0:i.isRTL(o.floating)),a=c(n),l=u(n),s="y"===g(n),m=["left","top"].includes(a)?-1:1,d=r&&s?-1:1,p=f(e,t);let{mainAxis:h,crossAxis:y,alignmentAxis:w}="number"==typeof p?{mainAxis:p,crossAxis:0,alignmentAxis:null}:{mainAxis:0,crossAxis:0,alignmentAxis:null,...p};return l&&"number"==typeof w&&(y="end"===l?-1*w:w),s?{x:y*d,y:h*m}:{x:h*m,y:y*d}}(e,t);return a===(null==(n=l.offset)?void 0:n.placement)&&null!=(i=l.arrow)&&i.alignmentOffset?{}:{x:o+s.x,y:r+s.y,data:{...s,placement:a}}}}},t.rectToClientRect=v,t.shift=function(t){return void 0===t&&(t={}),{name:"shift",options:t,async fn(e){const{x:n,y:i,placement:o}=e,{mainAxis:r=!0,crossAxis:a=!1,limiter:l={fn:t=>{let{x:e,y:n}=t;return{x:e,y:n}}},...u}=f(t,e),d={x:n,y:i},p=await A(e,u),h=g(c(o)),y=m(h);let w=d[y],x=d[h];if(r){const t="y"===y?"bottom":"right";w=s(w+p["y"===y?"top":"left"],w,w-p[t])}if(a){const t="y"===h?"bottom":"right";x=s(x+p["y"===h?"top":"left"],x,x-p[t])}const v=l.fn({...e,[y]:w,[h]:x});return{...v,data:{x:v.x-n,y:v.y-i}}}}},t.size=function(t){return void 0===t&&(t={}),{name:"size",options:t,async fn(e){const{placement:n,rects:i,platform:a,elements:l}=e,{apply:s=(()=>{}),...m}=f(t,e),d=await A(e,m),p=c(n),h=u(n),y="y"===g(n),{width:w,height:x}=i.floating;let v,b;"top"===p||"bottom"===p?(v=p,b=h===(await(null==a.isRTL?void 0:a.isRTL(l.floating))?"start":"end")?"left":"right"):(b=p,v="end"===h?"top":"bottom");const R=x-d[v],P=w-d[b],T=!e.middlewareData.shift;let D=R,O=P;if(y){const t=w-d.left-d.right;O=h||T?o(P,t):t}else{const t=x-d.top-d.bottom;D=h||T?o(R,t):t}if(T&&!h){const t=r(d.left,0),e=r(d.right,0),n=r(d.top,0),i=r(d.bottom,0);y?O=w-2*(0!==t||0!==e?t+e:r(d.left,d.right)):D=x-2*(0!==n||0!==i?n+i:r(d.top,d.bottom))}await s({...e,availableWidth:O,availableHeight:D});const E=await a.getDimensions(l.floating);return w!==E.width||x!==E.height?{reset:{rects:!0}}:{}}}}})); diff --git a/assets/vendor/floating-ui.dom.umd.min.js b/assets/vendor/floating-ui.dom.umd.min.js new file mode 100644 index 00000000..95b4c959 --- /dev/null +++ b/assets/vendor/floating-ui.dom.umd.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("@floating-ui/core")):"function"==typeof define&&define.amd?define(["exports","@floating-ui/core"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).FloatingUIDOM={},t.FloatingUICore)}(this,(function(t,e){"use strict";const n=Math.min,o=Math.max,i=Math.round,r=Math.floor,c=t=>({x:t,y:t});function l(t){return u(t)?(t.nodeName||"").toLowerCase():"#document"}function s(t){var e;return(null==t||null==(e=t.ownerDocument)?void 0:e.defaultView)||window}function f(t){var e;return null==(e=(u(t)?t.ownerDocument:t.document)||window.document)?void 0:e.documentElement}function u(t){return t instanceof Node||t instanceof s(t).Node}function a(t){return t instanceof Element||t instanceof s(t).Element}function d(t){return t instanceof HTMLElement||t instanceof s(t).HTMLElement}function h(t){return"undefined"!=typeof ShadowRoot&&(t instanceof ShadowRoot||t instanceof s(t).ShadowRoot)}function p(t){const{overflow:e,overflowX:n,overflowY:o,display:i}=v(t);return/auto|scroll|overlay|hidden|clip/.test(e+o+n)&&!["inline","contents"].includes(i)}function m(t){return["table","td","th"].includes(l(t))}function g(t){const e=y(),n=v(t);return"none"!==n.transform||"none"!==n.perspective||!!n.containerType&&"normal"!==n.containerType||!e&&!!n.backdropFilter&&"none"!==n.backdropFilter||!e&&!!n.filter&&"none"!==n.filter||["transform","perspective","filter"].some((t=>(n.willChange||"").includes(t)))||["paint","layout","strict","content"].some((t=>(n.contain||"").includes(t)))}function y(){return!("undefined"==typeof CSS||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}function w(t){return["html","body","#document"].includes(l(t))}function v(t){return s(t).getComputedStyle(t)}function x(t){return a(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function b(t){if("html"===l(t))return t;const e=t.assignedSlot||t.parentNode||h(t)&&t.host||f(t);return h(e)?e.host:e}function T(t){const e=b(t);return w(e)?t.ownerDocument?t.ownerDocument.body:t.body:d(e)&&p(e)?e:T(e)}function L(t,e,n){var o;void 0===e&&(e=[]),void 0===n&&(n=!0);const i=T(t),r=i===(null==(o=t.ownerDocument)?void 0:o.body),c=s(i);return r?e.concat(c,c.visualViewport||[],p(i)?i:[],c.frameElement&&n?L(c.frameElement):[]):e.concat(i,L(i,[],n))}function R(t){const e=v(t);let n=parseFloat(e.width)||0,o=parseFloat(e.height)||0;const r=d(t),c=r?t.offsetWidth:n,l=r?t.offsetHeight:o,s=i(n)!==c||i(o)!==l;return s&&(n=c,o=l),{width:n,height:o,$:s}}function E(t){return a(t)?t:t.contextElement}function C(t){const e=E(t);if(!d(e))return c(1);const n=e.getBoundingClientRect(),{width:o,height:r,$:l}=R(e);let s=(l?i(n.width):n.width)/o,f=(l?i(n.height):n.height)/r;return s&&Number.isFinite(s)||(s=1),f&&Number.isFinite(f)||(f=1),{x:s,y:f}}const O=c(0);function S(t){const e=s(t);return y()&&e.visualViewport?{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}:O}function F(t,n,o,i){void 0===n&&(n=!1),void 0===o&&(o=!1);const r=t.getBoundingClientRect(),l=E(t);let f=c(1);n&&(i?a(i)&&(f=C(i)):f=C(t));const u=function(t,e,n){return void 0===e&&(e=!1),!(!n||e&&n!==s(t))&&e}(l,o,i)?S(l):c(0);let d=(r.left+u.x)/f.x,h=(r.top+u.y)/f.y,p=r.width/f.x,m=r.height/f.y;if(l){const t=s(l),e=i&&a(i)?s(i):i;let n=t,o=n.frameElement;for(;o&&i&&e!==n;){const t=C(o),e=o.getBoundingClientRect(),i=v(o),r=e.left+(o.clientLeft+parseFloat(i.paddingLeft))*t.x,c=e.top+(o.clientTop+parseFloat(i.paddingTop))*t.y;d*=t.x,h*=t.y,p*=t.x,m*=t.y,d+=r,h+=c,n=s(o),o=n.frameElement}}return e.rectToClientRect({width:p,height:m,x:d,y:h})}const D=[":popover-open",":modal"];function P(t){return D.some((e=>{try{return t.matches(e)}catch(t){return!1}}))}function H(t){return F(f(t)).left+x(t).scrollLeft}function W(t,n,i){let r;if("viewport"===n)r=function(t,e){const n=s(t),o=f(t),i=n.visualViewport;let r=o.clientWidth,c=o.clientHeight,l=0,u=0;if(i){r=i.width,c=i.height;const t=y();(!t||t&&"fixed"===e)&&(l=i.offsetLeft,u=i.offsetTop)}return{width:r,height:c,x:l,y:u}}(t,i);else if("document"===n)r=function(t){const e=f(t),n=x(t),i=t.ownerDocument.body,r=o(e.scrollWidth,e.clientWidth,i.scrollWidth,i.clientWidth),c=o(e.scrollHeight,e.clientHeight,i.scrollHeight,i.clientHeight);let l=-n.scrollLeft+H(t);const s=-n.scrollTop;return"rtl"===v(i).direction&&(l+=o(e.clientWidth,i.clientWidth)-r),{width:r,height:c,x:l,y:s}}(f(t));else if(a(n))r=function(t,e){const n=F(t,!0,"fixed"===e),o=n.top+t.clientTop,i=n.left+t.clientLeft,r=d(t)?C(t):c(1);return{width:t.clientWidth*r.x,height:t.clientHeight*r.y,x:i*r.x,y:o*r.y}}(n,i);else{const e=S(t);r={...n,x:n.x-e.x,y:n.y-e.y}}return e.rectToClientRect(r)}function M(t,e){const n=b(t);return!(n===e||!a(n)||w(n))&&("fixed"===v(n).position||M(n,e))}function z(t,e,n){const o=d(e),i=f(e),r="fixed"===n,s=F(t,!0,r,e);let u={scrollLeft:0,scrollTop:0};const a=c(0);if(o||!o&&!r)if(("body"!==l(e)||p(i))&&(u=x(e)),o){const t=F(e,!0,r,e);a.x=t.x+e.clientLeft,a.y=t.y+e.clientTop}else i&&(a.x=H(i));return{x:s.left+u.scrollLeft-a.x,y:s.top+u.scrollTop-a.y,width:s.width,height:s.height}}function A(t,e){return d(t)&&"fixed"!==v(t).position?e?e(t):t.offsetParent:null}function V(t,e){const n=s(t);if(!d(t)||P(t))return n;let o=A(t,e);for(;o&&m(o)&&"static"===v(o).position;)o=A(o,e);return o&&("html"===l(o)||"body"===l(o)&&"static"===v(o).position&&!g(o))?n:o||function(t){let e=b(t);for(;d(e)&&!w(e);){if(g(e))return e;e=b(e)}return null}(t)||n}const N={convertOffsetParentRelativeRectToViewportRelativeRect:function(t){let{elements:e,rect:n,offsetParent:o,strategy:i}=t;const r="fixed"===i,s=f(o),u=!!e&&P(e.floating);if(o===s||u&&r)return n;let a={scrollLeft:0,scrollTop:0},h=c(1);const m=c(0),g=d(o);if((g||!g&&!r)&&(("body"!==l(o)||p(s))&&(a=x(o)),d(o))){const t=F(o);h=C(o),m.x=t.x+o.clientLeft,m.y=t.y+o.clientTop}return{width:n.width*h.x,height:n.height*h.y,x:n.x*h.x-a.scrollLeft*h.x+m.x,y:n.y*h.y-a.scrollTop*h.y+m.y}},getDocumentElement:f,getClippingRect:function(t){let{element:e,boundary:i,rootBoundary:r,strategy:c}=t;const s=[..."clippingAncestors"===i?function(t,e){const n=e.get(t);if(n)return n;let o=L(t,[],!1).filter((t=>a(t)&&"body"!==l(t))),i=null;const r="fixed"===v(t).position;let c=r?b(t):t;for(;a(c)&&!w(c);){const e=v(c),n=g(c);n||"fixed"!==e.position||(i=null),(r?!n&&!i:!n&&"static"===e.position&&i&&["absolute","fixed"].includes(i.position)||p(c)&&!n&&M(t,c))?o=o.filter((t=>t!==c)):i=e,c=b(c)}return e.set(t,o),o}(e,this._c):[].concat(i),r],f=s[0],u=s.reduce(((t,i)=>{const r=W(e,i,c);return t.top=o(r.top,t.top),t.right=n(r.right,t.right),t.bottom=n(r.bottom,t.bottom),t.left=o(r.left,t.left),t}),W(e,f,c));return{width:u.right-u.left,height:u.bottom-u.top,x:u.left,y:u.top}},getOffsetParent:V,getElementRects:async function(t){const e=this.getOffsetParent||V,n=this.getDimensions;return{reference:z(t.reference,await e(t.floating),t.strategy),floating:{x:0,y:0,...await n(t.floating)}}},getClientRects:function(t){return Array.from(t.getClientRects())},getDimensions:function(t){const{width:e,height:n}=R(t);return{width:e,height:n}},getScale:C,isElement:a,isRTL:function(t){return"rtl"===v(t).direction}};const B=e.autoPlacement,I=e.shift,k=e.flip,j=e.size,q=e.hide,U=e.arrow,X=e.inline,Y=e.limitShift;Object.defineProperty(t,"detectOverflow",{enumerable:!0,get:function(){return e.detectOverflow}}),Object.defineProperty(t,"offset",{enumerable:!0,get:function(){return e.offset}}),t.arrow=U,t.autoPlacement=B,t.autoUpdate=function(t,e,i,c){void 0===c&&(c={});const{ancestorScroll:l=!0,ancestorResize:s=!0,elementResize:u="function"==typeof ResizeObserver,layoutShift:a="function"==typeof IntersectionObserver,animationFrame:d=!1}=c,h=E(t),p=l||s?[...h?L(h):[],...L(e)]:[];p.forEach((t=>{l&&t.addEventListener("scroll",i,{passive:!0}),s&&t.addEventListener("resize",i)}));const m=h&&a?function(t,e){let i,c=null;const l=f(t);function s(){var t;clearTimeout(i),null==(t=c)||t.disconnect(),c=null}return function f(u,a){void 0===u&&(u=!1),void 0===a&&(a=1),s();const{left:d,top:h,width:p,height:m}=t.getBoundingClientRect();if(u||e(),!p||!m)return;const g={rootMargin:-r(h)+"px "+-r(l.clientWidth-(d+p))+"px "+-r(l.clientHeight-(h+m))+"px "+-r(d)+"px",threshold:o(0,n(1,a))||1};let y=!0;function w(t){const e=t[0].intersectionRatio;if(e!==a){if(!y)return f();e?f(!1,e):i=setTimeout((()=>{f(!1,1e-7)}),100)}y=!1}try{c=new IntersectionObserver(w,{...g,root:l.ownerDocument})}catch(t){c=new IntersectionObserver(w,g)}c.observe(t)}(!0),s}(h,i):null;let g,y=-1,w=null;u&&(w=new ResizeObserver((t=>{let[n]=t;n&&n.target===h&&w&&(w.unobserve(e),cancelAnimationFrame(y),y=requestAnimationFrame((()=>{var t;null==(t=w)||t.observe(e)}))),i()})),h&&!d&&w.observe(h),w.observe(e));let v=d?F(t):null;return d&&function e(){const n=F(t);!v||n.x===v.x&&n.y===v.y&&n.width===v.width&&n.height===v.height||i();v=n,g=requestAnimationFrame(e)}(),i(),()=>{var t;p.forEach((t=>{l&&t.removeEventListener("scroll",i),s&&t.removeEventListener("resize",i)})),null==m||m(),null==(t=w)||t.disconnect(),w=null,d&&cancelAnimationFrame(g)}},t.computePosition=(t,n,o)=>{const i=new Map,r={platform:N,...o},c={...r.platform,_c:i};return e.computePosition(t,n,{...r,platform:c})},t.flip=k,t.getOverflowAncestors=L,t.hide=q,t.inline=X,t.limitShift=Y,t.platform=N,t.shift=I,t.size=j})); diff --git a/config/config.exs b/config/config.exs index 913ad41a..04108797 100644 --- a/config/config.exs +++ b/config/config.exs @@ -47,7 +47,7 @@ config :esbuild, args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), cd: Path.expand("../assets", __DIR__), - env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + env: %{"NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:#{Path.expand("../assets/vendor", __DIR__)}"} ] # Configure tailwind (the version is required) diff --git a/config/dev.exs b/config/dev.exs index a9f55e26..a1d3f71f 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -73,7 +73,8 @@ config :vyasa, VyasaWeb.Endpoint, ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", ~r"lib/vyasa_web/(controllers|live|components)/.*(ex|heex)$" - ] + ], + web_console_logger: true ] # Enable dev routes for dashboard and mailbox diff --git a/config/runtime.exs b/config/runtime.exs index 49493441..b20384f2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -116,6 +116,6 @@ if config_env() == :prod do # # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. # - else - Vyasa.Parser.Env.load_file('.env') +else + Vyasa.Parser.Env.load_file(".env") end diff --git a/docs/initial_db_helpers.livemd b/docs/initial_db_helpers.livemd deleted file mode 100644 index 9bd49236..00000000 --- a/docs/initial_db_helpers.livemd +++ /dev/null @@ -1,781 +0,0 @@ -# 2024 - An Ecto Odessy [Initial DB Helpers] - -## Root Section -- Common Utils - -### Supports Recompilation from within Livebook - -```elixir -defmodule R do - def recompile() do - Mix.Task.reenable("app.start") - Mix.Task.reenable("compile") - Mix.Task.reenable("compile.all") - compilers = Mix.compilers() - Enum.each(compilers, &Mix.Task.reenable("compile.#{&1}")) - Mix.Task.run("compile.all") - end -end - -R.recompile() -``` - -### Source Extraction Modules - -Convert json --> struct --> using changeset --> insert into repo - - - -#### Gita Source Extraction Module - -```elixir -alias Vyasa.Written.{Chapter, Source, Translation} -alias Vyasa.Medium -alias Vyasa.Repo - -defmodule G do - @gita_sub_dir Path.expand("./priv/static/corpus/gita") - - @verses "#{@gita_sub_dir}/verse.json" - @translations "#{@gita_sub_dir}/translation.json" - @chapters "#{@gita_sub_dir}/chapters.json" - - alias Vyasa.Written.{Chapter} - - def read_verses() do - @verses - |> File.read!() - |> Jason.decode!() - end - - def read_translations() do - @translations - |> File.read!() - |> Jason.decode!() - end - - def read_chapters() do - @chapters - |> File.read!() - |> Jason.decode!() - end - - def get_translation_by_verse(id) do - read_translations() - |> Enum.find(fn %{"verseNumber" => no, "authorName" => a} -> - no == id and a == "Dr. S. Sankaranarayan" - end) - end - - def create_verse(verse, source_id) do - updated = - verse - |> Map.put("source_id", source_id) - |> Map.put("chapter_no", verse["chapter_number"]) - |> Map.put("body", verse["text"]) - |> Map.put("no", verse["verse_number"]) - - updated - end - - def get_verses_for_chapter(chap_no, src_id) do - read_verses() - |> Enum.filter(fn verse -> verse["chapter_number"] == chap_no end) - |> Enum.map(fn verse -> G.create_verse(verse, src_id) end) - end - - def create_translation_for_chapter(chapter) do - %{ - type: "chapters", - lang: "hi", - target: %{ - title: chapter["name"], - body: chapter["chapter_summary_hindi"] - } - } - end - - def create_chapter(%{} = chapter, source_id) do - %{ - no: chapter["chapter_number"], - title: chapter["name"], - body: chapter["chapter_summary"], - source_id: source_id - } - - # |> dbg() - end - - def create_chapter(%{} = chapter, [%{} = _head | _rest] = _verses, source_id) do - IO.puts("CHECK THIS CHAPTER OUT") - IO.inspect(chapter) - - %{ - source_id: source_id, - no: chapter["chapter_number"] - } - end - - def get_chapters([%{} = _head | _rest] = verses, source_id) do - @chapters - |> File.read!() - |> Jason.decode!() - |> Enum.map(fn chap -> create_chapter(chap, verses, source_id) end) - end - - def get_chapters(source_id) do - @chapters - |> File.read!() - |> Jason.decode!() - |> Enum.map(fn chap -> create_chapter(chap, source_id) end) - end - - def add_relevant_verses(chapter, verses) do - relevant_verses = - verses - |> Enum.filter(fn verse -> verse.chapter_no == chapter.no end) - |> Enum.map(fn verse -> Map.put(verse, :id, Ecto.UUID.generate()) end) - - %{chapter | verses: relevant_verses} - end - - def insert_verses_into_chapters(chapters, verses) do - chapters - |> Enum.map(fn chap -> add_relevant_verses(chap, verses) end) - end - - def get_source([%{} = _h | _r] = chapters, [%{} = _head | _rest] = verses, source_id) do - %{ - id: source_id, - title: "Gita", - chapters: chapters, - verses: verses - } - end - - def create_chapter_changeset(chap, verses, src_id) do - Chapter.changeset( - %Chapter{ - no: chap["chapter_number"], - source_id: src_id, - title: chap["name"], - body: chap["chapter_summary"] - }, - %{ - verses: verses - } - ) - end - - def create_chap_translation_changeset(%Chapter{} = inserted_chap, %{} = chap) do - Translation.gen_changeset( - %Translation{}, - %{ - lang: "en", - body: chap["chapter_summary"], - target: %{ - title: chap["name_meaning"], - translit_title: chap["name_transliterated"], - body: chap["chapter_summary"] - } - }, - inserted_chap - ) - end - - def create_translation_changeset_for_verse( - inserted_verse, - %{"verse_order" => verse_id} = relevant_verse - ) do - dbg(relevant_verse) - - Translation.gen_changeset( - %Translation{}, - %{ - lang: "en", - # body: verse["transliteration"], - target: %{ - body: get_translation_by_verse(verse_id)["description"], - body_translit_meant: relevant_verse["word_meanings"], - body_translit: relevant_verse["transliteration"] - } - }, - inserted_verse - ) - |> dbg() - end - - def create_translation_changesets_for_verses(inserted_verses, relevant_verses) do - Enum.zip(inserted_verses, relevant_verses) - |> Enum.map(fn {inserted_verse, relevant_verse} -> - create_translation_changeset_for_verse(inserted_verse, relevant_verse) - end) - end -end -``` - -#### Hanuman Chalisa Source Extraction Module - -```elixir -defmodule H do - alias Vyasa.Written.{Chapter, Source, Translation} - alias Vyasa.Medium - alias Vyasa.Repo - - @chalisa_events """ - start :- 00:00 - Shloka 1:- 00:02 - Shloka 2 :- 00:24 - Shloka 3:- 00:56 - Shloka 4:- 01:07 - Shloka 5:- 01:23 - Shloka 6:- 01:33 - Shloka 7:- 01:44 - Shloka 8:- 01:55 - Shloka 9:- 02:10 - Shloka 10:- 02:21 - Shloka 11:- 02:32 - Shloka 12:- 02:43 - Shloka 13:- 02:58 - Shloka 14:- 03:09 - Shloka 15:- 03:19 - Shloka 16:- 03:30 - Shloka 17:- 03:45 - Shloka 18:- 03:54 - Shloka 19:- 04:07 - Shloka 20:- 04:18 - Shloka 21:- 04:33 - Shloka 22:- 04:44 - Shloka 23:- 04:55 - Shloka 24:- 05:05 - Shloka 25:- 05:21 - Shloka 26:- 05:32 - Shloka 27:- 05:42 - Shloka 28:- 05:53 - Shloka 29:- 06:09 - Shloka 30:- 06:19 - Shloka 31:- 06:30 - Shloka 32 :- 06:41 - Shloka 33:- 06:56 - Shloka 34 :- 07:07 - Shloka 35 :- 07:17 - Shloka 36 :- 07:28 - Shloka 37:- 07:43 - Shloka 38 :- 07:54 - Shloka 39:- 08:05 - Shloka 40:- 08:16 - Shloka 41:- 08:31 - Shloka 42:- 08:41 - Shloka 43:- 09:00 - end:- 09:42 - """ - - @chalisa_json_path Path.expand("./priv/static/corpus/shlokam.org/hanumanchalisa.json") - - def read_verses() do - @chalisa_json_path - |> File.read!() - |> Jason.decode!() - end -end -``` - -#### Seeders - -```elixir -defmodule SourceSeeders do - alias Vyasa.Repo - alias G - alias Vyasa.Written.{Source, Chapter, Verse, Translation} - alias Vyasa.Medium.{Event, Voice, Video} - - @gulshan_kumar_chalisa_uri "AETFvQonfV8" - @gita_video_uri "z4IQ4Laivtk" - - # generic - def insert_source(uuid, source_title \\ "gita") do - source_changeset = - Source.gen_changeset(%Source{}, %{ - id: uuid, - title: source_title - }) - - Repo.insert!(source_changeset) - end - - def associate_video_to_voice(inserted_voice, video_attrs) do - Video.gen_changeset(%Video{}, video_attrs, inserted_voice) - |> Repo.insert() - end - - def insert_gita_chapter(chap, source_id) do - relevant_verses = G.get_verses_for_chapter(chap["chapter_number"], source_id) - chap_changeset = G.create_chapter_changeset(chap, relevant_verses, source_id) - {:ok, %Chapter{} = inserted_chapter} = Repo.insert(chap_changeset) - %{verses: inserted_verses} = inserted_chapter - - changeset_chap_translation = G.create_chap_translation_changeset(inserted_chapter, chap) - {:ok, %Translation{} = _inserted_chap_translation} = Repo.insert(changeset_chap_translation) - - verses_translation_changesets = - G.create_translation_changesets_for_verses(inserted_verses, relevant_verses) - - _inserted_verse_translations = - verses_translation_changesets - |> Enum.map(fn c_set -> Repo.insert(c_set) end) - |> Enum.map(fn {:ok, inserted_verse_translation} -> inserted_verse_translation end) - - chap - end - - def insert_gita_chapters(source_id) do - G.read_chapters() - |> Enum.map(fn chap -> insert_gita_chapter(chap, source_id) end) - end - - # def insert_hanuman_chalisa_verses(source_id) do - # verses = H.get_verses(soure_id) - - # end - - @gita_chap_1_events """ - start :- 00:00 - Shloka 1:- 00:33 - Shloka 2 :- 00:49 - Shloka 3:- 01:06 - Shloka 4:- 01:19 - Shloka 5:- 01:32 - Shloka 6:- 01:46 - Shloka 7:- 02:00 - Shloka 8:- 02:15 - Shloka 9:- 02:28 - Shloka 10:- 02:42 - Shloka 11:- 02:56 - Shloka 12:- 03:09 - Shloka 13:- 03:22 - Shloka 14:- 03:36 - Shloka 15:- 03:49 - Shloka 16:- 04:02 - Shloka 17:- 04:14 - Shloka 18:- 04:27 - Shloka 19:- 04:40 - Shloka 20:- 04:54 - Shloka 21:- 05:07 - Shloka 22:- 05:23 - Shloka 23:- 05:36 - Shloka 24:- 05:50 - Shloka 25:- 06:05 - Shloka 26:- 06:18 - Shloka 27:- 06:32 - Shloka 28:- 06:46 - Shloka 29:- 07:01 - Shloka 30:- 07:13 - Shloka 31:- 07:26 - Shloka 32 :- 07:38 - Shloka 33:- 07:51 - Shloka 34 :- 08:05 - Shloka 35 :- 08:18 - Shloka 36 :- 08:31 - Shloka 37:- 08:44 - Shloka 38 :- 08:57 - Shloka 39:- 09:09 - Shloka 40:- 09:22 - Shloka 41:- 09:35 - Shloka 42:- 09:48 - Shloka 43:- 10:02 - Shloka 44:- 10:16 - Shloka 45:- 10:29 - Shloka 46:- 10:40 - Shloka 47:- 10:53 - end:- 11:08 - """ - - def seed_gita_chapter_1() do - gita = Vyasa.Written.get_source_by_title("gita") - verses = Vyasa.Written.get_verses_in_chapter(1, gita.id) - - # creats a map using verse information - verse_lookup = Enum.into(for(%{id: id, no: verse_no} <- verses, do: {verse_no, id}), %{}) - - c1_path = Path.expand("./1.mp3", "media/gita") - - {:ok, - %Vyasa.Parser.MP3{ - duration: tot_d, - path: p - }} = Vyasa.Parser.MP3.parse(c1_path) - - {:ok, voice} = - Vyasa.Medium.create_voice(%{ - lang: "sa", - duration: tot_d, - file_path: c1_path, - source_id: gita.id, - chapter_no: 1 - }) - - {:ok, video} = associate_video_to_voice(voice, %{ext_uri: @gita_video_uri, type: "youtube"}) - - @gita_chap_1_events - |> String.split("\n") - |> Enum.map(fn x -> - x - |> String.split(":-") - |> Enum.map(&String.trim/1) - |> Enum.reduce([], fn - <<"Shloka"::utf8, sep::utf8, verse_no::binary>>, acc -> - [verse_lookup[String.to_integer(verse_no)] | acc] - - bin, acc -> - [bin | acc] - end) - end) - |> IO.inspect(limit: :infinity) - |> Enum.reduce( - [], - fn - [time, "start"], acc -> - [ - %Vyasa.Medium.Event{origin: 0, phase: "start", voice_id: voice.id, source_id: gita.id} - | acc - ] - - [time, "end"], [%{origin: o} = prev | acc] -> - [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) - d = (min * 60 + sec) * 1000 - - [ - %Vyasa.Medium.Event{ - origin: d, - duration: tot_d - d, - phase: "end", - voice_id: voice.id, - source_id: gita.id - } - | [%{prev | duration: d - o} | acc] - ] - - [time, id], [%{origin: o} = prev | acc] -> - [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) - d = (min * 60 + sec) * 1000 - - [ - %Vyasa.Medium.Event{origin: d, verse_id: id, voice_id: voice.id, source_id: gita.id} - | [%{prev | duration: d - o} | acc] - ] - - _, acc -> - acc - end - ) - |> Enum.map(&Vyasa.Medium.create_event(&1)) - end - - def seed_gita() do - uuid = Ecto.UUID.generate() - _source = insert_source(uuid, "gita") - _chaps = insert_gita_chapters(uuid) - seed_gita_chapter_1() - end - - @chalisa_events """ - start :- 00:00 - Shloka 1:- 00:02 - Shloka 2 :- 00:24 - Shloka 3:- 00:56 - Shloka 4:- 01:07 - Shloka 5:- 01:23 - Shloka 6:- 01:33 - Shloka 7:- 01:44 - Shloka 8:- 01:55 - Shloka 9:- 02:10 - Shloka 10:- 02:21 - Shloka 11:- 02:32 - Shloka 12:- 02:43 - Shloka 13:- 02:58 - Shloka 14:- 03:09 - Shloka 15:- 03:19 - Shloka 16:- 03:30 - Shloka 17:- 03:45 - Shloka 18:- 03:54 - Shloka 19:- 04:07 - Shloka 20:- 04:18 - Shloka 21:- 04:33 - Shloka 22:- 04:44 - Shloka 23:- 04:55 - Shloka 24:- 05:05 - Shloka 25:- 05:21 - Shloka 26:- 05:32 - Shloka 27:- 05:42 - Shloka 28:- 05:53 - Shloka 29:- 06:09 - Shloka 30:- 06:19 - Shloka 31:- 06:30 - Shloka 32 :- 06:41 - Shloka 33:- 06:56 - Shloka 34 :- 07:07 - Shloka 35 :- 07:17 - Shloka 36 :- 07:28 - Shloka 37:- 07:43 - Shloka 38 :- 07:54 - Shloka 39:- 08:05 - Shloka 40:- 08:16 - Shloka 41:- 08:31 - Shloka 42:- 08:41 - Shloka 43:- 09:00 - end:- 09:42 - """ - @chalisa_audio_path Path.expand("./hanuman_chalisa_gulshan_kumar.mp3", "media/chalisa") - def insert_hanuman_chalisa_events() do - chalisa = Vyasa.Written.get_source_by_title("hanuman_chalisa") - verses = Vyasa.Written.get_verses_in_chapter(1, chalisa.id) - verse_lookup = Enum.into(for(%{id: id, no: verse_no} <- verses, do: {verse_no, id}), %{}) - - # parse mp3 info: - {:ok, - %Vyasa.Parser.MP3{ - duration: tot_d, - path: p - }} = Vyasa.Parser.MP3.parse(@chalisa_audio_path) - - # handle voices: - {:ok, voice} = - Vyasa.Medium.create_voice(%{ - lang: "sa", - duration: tot_d, - file_path: @chalisa_audio_path, - source_id: chalisa.id, - chapter_no: 1 - }) - - # handle video: - {:ok, video} = - associate_video_to_voice(voice, %{ext_uri: @gulshan_kumar_chalisa_uri, type: "youtube"}) - - # now handle the events: - @chalisa_events - |> String.split("\n") - |> Enum.map(fn x -> - x - |> String.split(":-") - |> Enum.map(&String.trim/1) - |> Enum.reduce([], fn - <<"Shloka"::utf8, sep::utf8, verse_no::binary>>, acc -> - [verse_lookup[String.to_integer(verse_no)] | acc] - - bin, acc -> - [bin | acc] - end) - end) - |> IO.inspect(limit: :infinity) - |> Enum.reduce( - [], - fn - [time, "start"], acc -> - [ - %Vyasa.Medium.Event{ - origin: 0, - phase: "start", - voice_id: voice.id, - source_id: chalisa.id - } - | acc - ] - - [time, "end"], [%{origin: o} = prev | acc] -> - [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) - d = (min * 60 + sec) * 1000 - - [ - %Vyasa.Medium.Event{ - origin: d, - duration: tot_d - d, - phase: "end", - voice_id: voice.id, - source_id: chalisa.id - } - | [%{prev | duration: d - o} | acc] - ] - - [time, id], [%{origin: o} = prev | acc] -> - [min, sec] = time |> String.split(":") |> Enum.map(&String.to_integer/1) - d = (min * 60 + sec) * 1000 - - [ - %Vyasa.Medium.Event{ - origin: d, - verse_id: id, - voice_id: voice.id, - source_id: chalisa.id - } - | [%{prev | duration: d - o} | acc] - ] - - _, acc -> - acc - end - ) - |> Enum.map(&Vyasa.Medium.create_event(&1)) - end - - @chalisa_chap_body "The Hanuman Chalisa, composed by Goswami Tulsidas, is a 40-verse hymn dedicated to Lord Hanuman, highlighting his unwavering devotion to Lord Rama. It is a testament to Hanuman's strength, wisdom, and courage, as well as his role in Lord Rama's epic battles against evil. Reciting this hymn is believed to bestow blessings and protection from Hanuman, fostering spiritual growth and devotion to Lord Rama." - def insert_hanuman_chalisa_chapter(source, chalisa_verses) do - {:ok, inserted_chap} = - Chapter.changeset( - %Chapter{ - no: 1, - source_id: source.id, - title: "Hanuman Chalisa", - body: @chalisa_chap_body - }, - %{ - verses: - chalisa_verses - |> Enum.map(fn v -> - %{ - no: v["count"], - body: v["verse_sanskrit"], - source_id: source.id - } - end) - } - ) - |> Repo.insert() - |> dbg() - - Translation.gen_changeset( - %Translation{}, - %{ - lang: "en", - body: @chalisa_chap_body, - target: %{ - title: "Hanuman Chalisa", - translit_tile: "Hanuman Chalisa", - body: @chalisa_chap_body - }, - source_id: source.id - }, - inserted_chap - ) - |> Repo.insert() - |> dbg() - - inserted_chap - end - - def insert_hanuman_chalisa_verses(source, chalisa_verses) do - chalisa_verses - |> Enum.map(fn v -> - Verse.changeset(%Verse{}, %{ - no: v["count"], - body: v["verse_sanskrit"], - source_id: source.id - }) - end) - |> Enum.map(fn chset -> Repo.insert(chset) end) - |> Enum.map(fn {:ok, inserted_v} -> inserted_v end) - end - - def insert_hanuman_chalisa_verse_translations( - %Chapter{verses: inserted_verses} = _inserted_chap, - chalisa_verses - ) do - inserted_verses - |> Enum.zip(chalisa_verses) - |> Enum.map(fn {inserted_verse, v} -> - Translation.gen_changeset( - %Translation{}, - %{ - lang: "en", - target: %{ - body: v["verse_sanskrit"], - body_translit_meant: v["verse_meaning"], - body_translit: v["verse_trans"] - }, - source_id: inserted_verse.source_id, - # chapter_no: inserted_verse.chapter_no, # this assoc isn't supposed to be done @ verse-trans creation - verse_id: inserted_verse.id - }, - inserted_verse - ) - end) - |> Enum.map(fn trans_cset -> Repo.insert(trans_cset) end) - |> Enum.map(fn outcome -> elem(outcome, 1) end) - |> dbg() - end - - def seed_hanuman_chalisa() do - uuid = Ecto.UUID.generate() - %{"verses" => chalisa_verses} = _verse_info = H.read_verses() - - uuid - |> insert_source("hanuman_chalisa") - |> insert_hanuman_chalisa_chapter(chalisa_verses) - |> insert_hanuman_chalisa_verse_translations(chalisa_verses) - - insert_hanuman_chalisa_events() - end -end -``` - -#### DB Convenience Actions - -* purging of db -* seeding of db - -```elixir -defmodule DBHelper do - alias SourceSeeders - alias Vyasa.Repo - alias G - alias Vyasa.Written.{Source, Chapter, Verse, Translation} - alias Vyasa.Medium.{Video, Event, Voice} - - # alias Vyasa.Medium.{Voice, Event, Store, Track, Video, Writer} - - def purge_db() do - [Video, Event, Voice, Translation, Verse, Chapter, Source] - |> Enum.map(fn mod -> Repo.delete_all(mod) end) - end - - def seed_db() do - SourceSeeders.seed_gita() - SourceSeeders.seed_hanuman_chalisa() - end -end -``` - -#### Testing the purging and seeding of the db: - -```elixir -R.recompile() -DBHelper.purge_db() -DBHelper.seed_db() -``` - -### Exploring video-voice sync - -The following is a demo check that the seeding is working correctly. Once verified, the block may be removed. - -```elixir -R.recompile() - -alias Vyasa.Written -alias Vyasa.Written.{Source} -alias Vyasa.Medium.{Voice, Video, Store} -alias Vyasa.Medium -alias Vyasa.Repo - -%Source{id: chalisa_id} = chalisa = Written.get_source_by_title("hanuman_chalisa") -h_voice = Repo.get_by!(Voice, source_id: chalisa_id) |> Repo.preload([:video]) - -%Source{id: gita_id} = gita = Written.get_source_by_title("gita") -g_voice = Repo.get_by!(Voice, source_id: gita_id) |> Repo.preload([:video]) - -g_voice.video |> Medium.resolve_video_url() - -Medium.get_voice(chalisa_id, 1, "sa") |> Store.hydrate() -``` - -```elixir - -``` diff --git a/docs/migration_wow.livemd b/docs/migration_wow.livemd new file mode 100644 index 00000000..d996d809 --- /dev/null +++ b/docs/migration_wow.livemd @@ -0,0 +1,126 @@ +# Migration steps + +## Main Section + +The key steps for data migration are: + +1. we create the non-event structs +2. we creatre the event structs +3. we upload the mp3 files (to minio for local dev) + +1 and 2 shall rely on a json dump . +3 shall rely on mp3 files that we have. + + + +#### Defines the location of the json dump file that we are using + +```elixir +mypath = "#{File.cwd!()}/wow.json" +``` + +```elixir +# A module to support re-compilation from within a livebook cell. Call it from anywhere. +defmodule R do + def recompile() do + Mix.Task.reenable("app.start") + Mix.Task.reenable("compile") + Mix.Task.reenable("compile.all") + compilers = Mix.compilers() + Enum.each(compilers, &Mix.Task.reenable("compile.#{&1}")) + Mix.Task.run("compile.all") + end +end + +R.recompile() +``` + +```elixir +Vyasa.Release.rollback(Vyasa.Repo, 0) +``` + +```elixir +# 20241515170225 +Vyasa.Release.migrate() +``` + +#### inits the non-event structs + +```elixir +Path.expand(mypath, "data") +|> Vyasa.Corpus.Migrator.Restore.read() +# |> Vyasa.Written.create_source() +|> Enum.map(fn x -> + # &1["translations"] + e = + x["events"] + |> Enum.group_by(fn map -> map["voice_id"] end) + + t = + x["translations"] + |> Enum.group_by(fn m -> {m["type"], m["verse_id"] || m["chapter_no"]} end) + + v = + x["voices"] + |> Enum.group_by(fn m -> m["chapter_no"] end) + + # Map.keys(x) + %{ + x + | "chapters" => + Enum.map(x["chapters"], fn %{"no" => no} = c -> + c + |> Map.put("translations", t[{"chapters", no}]) + |> Map.put( + "voices", + (is_list(v[no]) && v[no] |> Enum.map(fn %{"id" => id} = v -> v end)) || [] + ) + end), + "verses" => + Enum.map(x["verses"], fn %{"id" => id} = v -> + v |> Map.put("translations", t[{"verses", id}]) + end) + } +end) +|> Enum.map( + &(%Vyasa.Written.Source{} + |> Vyasa.Written.Source.gen_changeset(&1) + |> Vyasa.Repo.insert!()) +) +``` + +#### Migrates the events + +Events are dependent on other structs, hence it's added in later + +```elixir +Path.expand(mypath, "data") +|> Vyasa.Corpus.Migrator.Restore.read() +|> Enum.map(fn x -> + # &1["translations"] + x["events"] + |> Enum.map(&(&1 |> Vyasa.Medium.create_event())) +end) +``` + +### Add in the voice files + +We already have necessary functions in order to upload to minio, so we can just call the changeset and pass it the map of information we want and let that changeset logic handle it. + +```elixir +alias Vyasa.Medium.Store +alias Vyasa.Medium + +# in the target media folder, put in the mp3 files. The names for the files should be the id itself and this is in a way "hardcoded". The ids must match. +target_media_folder = File.cwd!() <> "/media/voices" + +File.ls!(target_media_folder) +|> Enum.map(fn x -> + %{"id" => hd(String.split(x, ".mp3")), "file_path" => target_media_folder <> "/" <> x} +end) +|> Enum.map(fn v -> Vyasa.Medium.Voice.gen_changeset(%Medium.Voice{}, v) end) +``` + +```elixir +Vyasa.Repo.all(Vyasa.Medium.Video) +``` diff --git a/lib/utils/struct.ex b/lib/utils/struct.ex new file mode 100644 index 00000000..e866f364 --- /dev/null +++ b/lib/utils/struct.ex @@ -0,0 +1,24 @@ +defmodule Utils.Struct do + @moduledoc """ + Contains functions useful for struct operations + """ + @doc """ + Access elements nested within structs + """ + def get_in(struct, keys) when is_list(keys) do + do_get_in(struct, keys) + end + + defp do_get_in(nil, _keys), do: nil + defp do_get_in(value, []), do: value + defp do_get_in(map, [key | rest_keys]) when is_map(map) do + map + |> Map.get(key) + |> do_get_in(rest_keys) + end + defp do_get_in(struct, [key | rest_keys]) do + struct + |> Map.get(key) + |> do_get_in(rest_keys) + end +end diff --git a/lib/vyasa/adapters/bind.ex b/lib/vyasa/adapters/bind.ex deleted file mode 100644 index c3a46e4d..00000000 --- a/lib/vyasa/adapters/bind.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Vyasa.Adapters.Binding do - @moduledoc """ - Bindings that unite cross reference Archetypal Data Structs, they can be both persistent and virtual - """ - - use Ecto.Schema - import Ecto.Changeset - - alias Vyasa.Written.{Verse, Source, Chapter} - - - embedded_schema do - belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no - belongs_to :verse, Verse, foreign_key: :verse_id, type: :binary_id - belongs_to :source, Source, foreign_key: :source_id, type: :binary_id - end - - def changeset(event, attrs) do - event - |> cast(attrs, [:verse_id, :voice_id, :source_id]) - end - - def cast(attrs) do - %__MODULE__{} - |> Vyasa.Repo.preload(__schema__(:associations)) - |> cast(attrs, []) - |> put_all_assoc(attrs) - |> apply_action(:binded) - end - - def put_all_assoc(changeset, attrs) do - assocs = __schema__(:associations) - - Enum.reduce(assocs, changeset, fn assoc, acc -> - case Map.fetch(attrs, assoc) do - {:ok, value} -> Ecto.Changeset.put_assoc(acc, assoc, value) - :error -> acc - end - end) - end -end diff --git a/lib/vyasa/adapters/binding.ex b/lib/vyasa/adapters/binding.ex new file mode 100644 index 00000000..3608b76b --- /dev/null +++ b/lib/vyasa/adapters/binding.ex @@ -0,0 +1,112 @@ +defmodule Vyasa.Adapters.Binding do + @moduledoc """ + Bindings that unite cross referential Archetypal Data Structs, they can be both persistent and virtual + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Written.{Source, Chapter, Verse, Translation} + alias Vyasa.Sangh.{Comment} + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "bindings" do + field :w_type, Ecto.Enum, values: [:quote, :timestamp, :null] + + field :field_key, {:array, :string} + + field :node_id, :string, virtual: true + + belongs_to :verse, Verse, foreign_key: :verse_id, type: :binary_id + belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no + belongs_to :source, Source, foreign_key: :source_id, type: :binary_id + belongs_to :translation, Translation, foreign_key: :translation_id, type: :binary_id + belongs_to :comment, Comment, foreign_key: :comment_id, type: :binary_id + + embeds_one :window, Window, on_replace: :delete do + field(:line_number, :integer) + field(:start, :integer) + field(:end, :integer) + field(:quote, :string) + + field(:start_time, :integer) + field(:end_time, :integer) + end + end + + def changeset(event, attrs) do + event + |> cast(attrs, [:verse_id, :voice_id, :source_id]) + end + + + def windowing_changeset(%__MODULE__{} = binding, attrs) do + binding + |> cast(attrs, [:w_type, :verse_id, :chapter_no, :source_id]) + |> typed_window_switch(attrs) + |> Map.put(:repo_opts, [on_conflict: {:replace_all_except, [:id]}, conflict_target: :id]) + end + + + #when type changes + def typed_window_switch(changeset, %{w_type: type}), do: typed_window_switch(changeset, %{"w_type" => type}) + def typed_window_switch(changeset, %{"w_type" => type}) do + window_changeset = case type do + "quote" -> + "e_changeset(&1, &2) + "timestamp" -> + ×tamp_changeset(&1, &2) + _ -> + &null_changeset(&1, &2) + end + + cast_embed(changeset, :window, with: window_changeset) + end + + def typed_window_switch(changeset, _attrs), do: validate_required(changeset, [:w_type]) + + def quote_changeset(structure, attrs) do + structure + |> cast(attrs, [:line_number, :start, :end, :quote]) + |> validate_required([:line_number, :quote]) + end + + def timestamp_changeset(structure, attrs) do + structure + |> cast(attrs, [:start_time, :end_time]) + |> validate_required([:start_time, :end_time]) + end + + def null_changeset(structure, attrs) do + structure + |> cast(attrs, []) + end + + + def cast(attrs) do + %__MODULE__{} + |> Vyasa.Repo.preload(__schema__(:associations)) + |> cast(attrs, []) + |> put_all_assoc(attrs) + |> apply_action(:binded) + end + + def put_all_assoc(changeset, attrs) do + assocs = __schema__(:associations) + + Enum.reduce(assocs, changeset, fn assoc, acc -> + case Map.fetch(attrs, assoc) do + {:ok, value} -> Ecto.Changeset.put_assoc(acc, assoc, value) + :error -> acc + end + end) + end + + def field_lookup(%Verse{}), do: :verse_id + def field_lookup(%Chapter{}), do: :chapter_no + def field_lookup(%Source{}), do: :source_id + def field_lookup(%Translation{}), do: :translation_id + def field_lookup(%Comment{}), do: :comment_id + + def field_lookup(_), do: nil +end diff --git a/lib/vyasa/corpus/migrator/dump.ex b/lib/vyasa/corpus/migrator/dump.ex index fe225fb7..96517872 100644 --- a/lib/vyasa/corpus/migrator/dump.ex +++ b/lib/vyasa/corpus/migrator/dump.ex @@ -28,7 +28,8 @@ defmodule Vyasa.Corpus.Migrator.Dump do |> Repo.preload(:video) |> Vyasa.Medium.Store.hydrate() |> Enum.map(&Vyasa.Medium.Store.download(&1)) - %{record | voices: voices} + + %{record | voices: voices |> Repo.preload(:video)} end def hydrate_voices(r), do: r diff --git a/lib/vyasa/display/user_mode.ex b/lib/vyasa/display/user_mode.ex new file mode 100644 index 00000000..319aaee4 --- /dev/null +++ b/lib/vyasa/display/user_mode.ex @@ -0,0 +1,59 @@ +defmodule Vyasa.Display.UserMode do + @moduledoc """ + The UserMode struct is a way of representing user-modes and + is intended to be used as a container. + + Typically, + 1. they shall contain component-definitions that get passed as "args" for the DM layout + 2. they may contain callback functions that get triggerred on a particular button activity + + Modes: + 1. Reading + 2. Drafting + 3. Discussion(?) + """ + alias Vyasa.Display.UserMode + + @derive Jason.Encoder + defstruct [ + :mode, + :mode_icon_name, + :action_bar_component, + :control_panel_component + ] + + # defines static aspects of different modes: + @defs %{ + "read" => %{ + mode: "read", + mode_icon_name: "hero-book-open", + action_bar_component: VyasaWeb.MediaLive.MediaBridge, + control_panel_component: VyasaWeb.ControlPanel + }, + "draft" => %{ + mode: "draft", + mode_icon_name: "hero-pencil-square", + # TODO: add drafting form for this + # TODO: to test swaps of action bar component + action_bar_component: VyasaWeb.MediaLive.MediaBridge, + # action_bar_component: nil, + control_panel_component: VyasaWeb.ControlPanel + } + } + + def supported_modes, do: Map.keys(@defs) + + def get_initial_mode() do + mode = "read" + struct(UserMode, @defs[mode]) + end + + def get_mode(mode_name) when is_map_key(@defs, mode_name) do + struct(UserMode, @defs[mode_name]) + end + + # defaults to the intial mode + def get_mode(_) do + get_initial_mode() + end +end diff --git a/lib/vyasa/draft.ex b/lib/vyasa/draft.ex new file mode 100644 index 00000000..fc740c38 --- /dev/null +++ b/lib/vyasa/draft.ex @@ -0,0 +1,131 @@ +defmodule Vyasa.Draft do + @moduledoc """ + The Drafting Context for all your marking and binding needs + """ + + import Ecto.Query, warn: false + alias Vyasa.Adapters.Binding + alias Vyasa.Sangh.Mark + alias Vyasa.Sangh + alias Vyasa.Repo + + + + def bind_node(%{"selection" => ""} = node) do + bind_node(node, %Binding{}) + end + + def bind_node(%{"selection" => selection} = node) do + bind_node(Map.delete(node, "selection"), %Binding{:window => %{:quote => selection}}) + end + + def bind_node(%{"field" => field} = node, bind) do + bind_node(Map.delete(node, "field"), %{bind | field_key: String.split(field, "::") |> Enum.map(&(String.to_existing_atom(&1)))}) + end + + def bind_node(%{"node" => node, "node_id" => node_id}, %Binding{} = bind) do + n = node + |> String.to_existing_atom() + |> struct() + |> Binding.field_lookup() + + %{bind | n => node_id, :node_id => node_id} + end + + def create_comment([%Mark{} | _]= marks) do + Sangh.create_comment(%{marks: marks}) + end + @doc """ + Returns the list of marks. + + ## Examples + + iex> list_marks() + [%Mark{}, ...] + + """ + def list_marks do + Repo.all(Mark) + end + + @doc """ + Gets a single mark. + + Raises `Ecto.NoResultsError` if the Mark does not exist. + + ## Examples + + iex> get_mark!(123) + %Mark{} + + iex> get_mark!(456) + ** (Ecto.NoResultsError) + + """ + def get_mark!(id), do: Repo.get!(Mark, id) + + @doc """ + Creates a mark. + + ## Examples + + iex> create_mark(%{field: value}) + {:ok, %Mark{}} + + iex> create_mark(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_mark(attrs \\ %{}) do + %Mark{} + |> Mark.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a mark. + + ## Examples + + iex> update_mark(mark, %{field: new_value}) + {:ok, %Mark{}} + + iex> update_mark(mark, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_mark(%Mark{} = mark, attrs) do + mark + |> Mark.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a mark. + + ## Examples + + iex> delete_mark(mark) + {:ok, %Mark{}} + + iex> delete_mark(mark) + {:error, %Ecto.Changeset{}} + + """ + def delete_mark(%Mark{} = mark) do + Repo.delete(mark) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking mark changes. + + ## Examples + + iex> change_mark(mark) + %Ecto.Changeset{data: %Mark{}} + + """ + def change_mark(%Mark{} = mark, attrs \\ %{}) do + Mark.changeset(mark, attrs) + end +end diff --git a/lib/vyasa/medium.ex b/lib/vyasa/medium.ex index d89a54e4..e824edb9 100644 --- a/lib/vyasa/medium.ex +++ b/lib/vyasa/medium.ex @@ -1,11 +1,9 @@ defmodule Vyasa.Medium do - import Ecto.Query, warn: false alias Vyasa.Medium.{Video, Voice, Event} alias Vyasa.Medium alias Vyasa.Repo - @doc """ Gets a single voice. @@ -28,14 +26,16 @@ defmodule Vyasa.Medium do def get_voice(source_id, chapter_no, lang) do from(v in Voice, where: v.source_id == ^source_id and v.lang == ^lang and v.chapter_no == ^chapter_no, - preload: [:events, :video]) + preload: [:events, :video] + ) |> Repo.one() end def get_voices!(%Voice{source_id: src_id, chapter_no: c_no, lang: l}) do from(v in Voice, where: v.source_id == ^src_id and v.chapter_no == ^c_no and v.lang == ^l, - preload: [:events]) + preload: [:events, :source] + ) |> Repo.all() end @@ -44,19 +44,18 @@ defmodule Vyasa.Medium do Currently mainly used for youtube videos, possibly more in the future. """ - def resolve_video_url(%Video{ - type: type, - ext_uri: ext_uri, - } = _video) do - + def resolve_video_url( + %Video{ + type: type, + ext_uri: ext_uri + } = _video + ) do cond do type == "youtube" -> "https://www.youtube.com/watch?v=#{ext_uri}" true -> ext_uri end - end - @doc """ Creates a voice. @@ -137,17 +136,18 @@ defmodule Vyasa.Medium do def get_event!(id), do: Repo.get!(Event, id) def get_event_by_order!(%Event{origin: origin, voice_id: v_id}, order) do - #TODO merge Sangh Filters to fix -1 order case for origin backwards operators - (from e in Event, + # TODO merge Sangh Filters to fix -1 order case for origin backwards operators + from(e in Event, preload: [:voice, :verse], where: e.origin >= ^origin and e.voice_id == ^v_id, order_by: e.origin, offset: ^order, - limit: 1) + limit: 1 + ) |> Repo.one!() end - @doc """ + @doc """ Creates a event. ## Examples @@ -161,10 +161,12 @@ defmodule Vyasa.Medium do """ def create_event(attrs \\ %{}) + def create_event(%Event{} = event) do event |> Repo.insert() end + def create_event(attrs) do %Event{} |> Event.changeset(attrs) @@ -222,5 +224,4 @@ defmodule Vyasa.Medium do |> List.first() |> Medium.Store.hydrate() end - end diff --git a/lib/vyasa/medium/meta.ex b/lib/vyasa/medium/meta.ex index 2048bd66..88552896 100644 --- a/lib/vyasa/medium/meta.ex +++ b/lib/vyasa/medium/meta.ex @@ -1,24 +1,20 @@ - defmodule Vyasa.Medium.Meta do - @moduledoc """ - Meta is a medium-agnostic struct for tracking metadata. - """ +defmodule Vyasa.Medium.Meta do + @moduledoc """ + Meta is a medium-agnostic generic struct for tracking metadata for that medium doesn't entangle itself with DB meta. + We can construct adapters to specifc metadata for specific mediums from here + ## Adapters + iex> from_voice(%Voice{meta: meta}) - alias Vyasa.Medium.Meta + Since this relates to rich media, the intent is also to contain information that is helpful for interfacing + with APIs like the MediaSessions API (Browser). + """ - defstruct [ - title: nil, - artists: [], - duration: 0, # time, in ms - file_path: nil, - ] - - defimpl Jason.Encoder, for: Meta do - def encode(%Meta{ title: title, artists: artists, duration: duration, file_path: file_path }, opts) do - %{ title: title, artists: artists, duration: duration, file_path: file_path } - |> Jason.Encode.map(opts) - end - end - - - - end + @derive Jason.Encoder + defstruct title: nil, + artists: [], + album: nil, + artwork: [], + # time, in ms + duration: 0, + file_path: nil +end diff --git a/lib/vyasa/medium/playback.ex b/lib/vyasa/medium/playback.ex index a195ad4d..5c035f39 100644 --- a/lib/vyasa/medium/playback.ex +++ b/lib/vyasa/medium/playback.ex @@ -1,50 +1,91 @@ - defmodule Vyasa.Medium.Playback do - @moduledoc """ - The Playback struct is a medium-agnostic way of representing the playback of a generic "media". - It shall be a reference struct which audio/video players shall use to sync up with each other. - """ - alias Vyasa.Medium.{Playback, Meta} +defmodule Vyasa.Medium.Playback do + @moduledoc """ + The Playback struct is a medium-agnostic way of representing the playback of a generic "media". + It shall be a reference struct which audio/video players shall use to sync up with each other. + """ + alias Vyasa.Medium.{Playback, Meta, Voice} - defstruct [ - :medium, - meta: %Meta{}, - playing?: false, + @derive Jason.Encoder + defstruct [ + :medium, + meta: %Meta{}, + playing?: false, + played_at: nil, + paused_at: nil, + # time unit: millseconds + elapsed: 0, + current_time: 0 + ] + + def new(%{} = attrs) do + %Vyasa.Medium.Playback{ + playing?: attrs.playing?, + meta: attrs.meta, + # timestamps played_at: nil, + # timestamps paused_at: nil, - elapsed: 0, # time unit: millseconds - current_time: 0 - ] - - defimpl Jason.Encoder, for: Playback do - def encode(%Playback{medium: medium, meta: meta, playing?: playing, played_at: played_at, paused_at: paused_at, elapsed: elapsed, current_time: current_time}, opts) do - %{ medium: medium, meta: meta, playing?: playing, played_at: played_at, paused_at: paused_at, elapsed: elapsed, current_time: current_time } - |> Jason.Encode.map(opts) - end - end + elapsed: 0 + } + end - def new(%{} = attrs) do - %Vyasa.Medium.Playback{ - playing?: attrs.playing?, - meta: attrs.meta, - played_at: nil, # timestamps - paused_at: nil, # timestamps - elapsed: 0, # seconds TODO: convert to ms to standardise w HTML players? - } - end + def create_playback( + %Voice{ + events: _voice_events, + title: title, + file_path: file_path, + duration: duration, + meta: + %{ + artists: artists, + album: album, + artwork: artwork + } = _meta + } = _voice, + %{ + src: _src, + type: _type, + sizes: _sizes + } = generated_artwork + ) do + init_playback(%Meta{ + title: title, + artists: artists, + album: album, + artwork: + case artwork do + works when is_list(works) -> [generated_artwork | works] + _ -> [generated_artwork] + end, + duration: duration, + file_path: file_path + }) + end - def init_playback(%Meta{} = meta) do - Playback.new(%{ - playing?: false, - meta: meta, - }) - end - - def init_playback() do - Playback.new(%{ - playing?: false, - meta: nil, - }) - end + def init_playback( + %Meta{ + title: _title, + artists: _artists, + album: _album, + artwork: _artwork, + duration: _duration, + file_path: _file_path + } = meta + ) do + Playback.new(%{ + playing?: false, + meta: meta + }) + end + def init_playback(nil) do + init_playback() + end - end + def init_playback() do + Playback.new(%{ + playing?: false, + meta: nil + }) + end +end diff --git a/lib/vyasa/medium/video.ex b/lib/vyasa/medium/video.ex index 5f80c1c4..c1d8375e 100644 --- a/lib/vyasa/medium/video.ex +++ b/lib/vyasa/medium/video.ex @@ -18,7 +18,6 @@ defmodule Vyasa.Medium.Video do def changeset(video, attrs) do video |> cast(attrs, [:type, :ext_uri]) - |> validate_required([:title]) end def gen_changeset(video, attrs, %Voice{id: voice_id}) do diff --git a/lib/vyasa/medium/voice.ex b/lib/vyasa/medium/voice.ex index 9ccb0ede..a5051c96 100644 --- a/lib/vyasa/medium/voice.ex +++ b/lib/vyasa/medium/voice.ex @@ -12,20 +12,25 @@ defmodule Vyasa.Medium.Voice do field :duration, :integer field :file_path, :string, virtual: true - ## FIXME: change field name to artists since it's an array.. embeds_one :meta, VoiceMetadata do - field(:artist, {:array, :string}) + field(:artists, {:array, :string}) + field(:album, :string) + field(:artwork, {:array, {:map, :string}}) end has_one :video, Video, references: :id, foreign_key: :voice_id - has_many :events, Event, references: :id, foreign_key: :voice_id, preload_order: [asc: :origin] + + has_many :events, Event, + references: :id, + foreign_key: :voice_id, + preload_order: [asc: :origin] belongs_to :track, Track, references: :id, foreign_key: :track_id belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no belongs_to :source, Source, references: :id, foreign_key: :source_id, type: :binary_id timestamps(type: :utc_datetime) - end + end @doc false @@ -53,14 +58,15 @@ defmodule Vyasa.Medium.Voice do def meta_changeset(voice, attrs) do voice - |> cast(attrs, [:artist]) + |> cast(attrs, [:artists]) end def file_upload(%Ecto.Changeset{changes: %{file_path: _} = changes} = ec) do - ext_path = apply_changes(ec) - |> Vyasa.Medium.Writer.run() - |> then(&elem(&1, 1).key) - |> Vyasa.Medium.Store.get!() + ext_path = + apply_changes(ec) + |> Vyasa.Medium.Writer.run() + |> then(&elem(&1, 1).key) + |> Vyasa.Medium.Store.get!() %{ec | changes: %{changes | file_path: ext_path}} end diff --git a/lib/vyasa/parser/env.ex b/lib/vyasa/parser/env.ex index 90923f52..88910330 100644 --- a/lib/vyasa/parser/env.ex +++ b/lib/vyasa/parser/env.ex @@ -313,7 +313,7 @@ defmodule Vyasa.Parser.Env do defp maybe_linefeed(%Continuation{start_quote: "\""}, input) do if String.ends_with?(input, "\\") do - String.slice(input, 0..-2) + String.slice(input, 0..-2//-1) else input <> "\n" end diff --git a/lib/vyasa/pubsub.ex b/lib/vyasa/pubsub.ex index b795c373..e9cfe08c 100644 --- a/lib/vyasa/pubsub.ex +++ b/lib/vyasa/pubsub.ex @@ -3,6 +3,7 @@ defmodule Vyasa.PubSub do Publish Subscriber Pattern """ alias Phoenix.PubSub + alias Vyasa.Medium.{Voice} def subscribe(topic, opts \\ []) do PubSub.subscribe(Vyasa.PubSub, topic, opts) @@ -15,6 +16,7 @@ defmodule Vyasa.PubSub do def publish({:ok, message}, event, topics) when is_list(topics) do topics |> Enum.map(fn topic -> publish(message, event, topic) end) + {:ok, message} end @@ -23,18 +25,24 @@ defmodule Vyasa.PubSub do {:ok, message} end + @doc """ + Publishes %Voice{} structs, any duplicate message check shall be done implicitly, based on the + struct that is being passed. + """ + def publish(%Voice{} = voice, event, sess_id) do + msg = {__MODULE__, event, voice} + PubSub.broadcast(__MODULE__, "media:session:#{sess_id}", msg) + voice + end + def publish(message, event, topics) when is_list(topics) do topics |> Enum.map(fn topic -> publish(message, event, topic) end) message end - def publish(%Vyasa.Medium.Voice{} = voice, event, sess_id) do - PubSub.broadcast(__MODULE__, "media:session:#{sess_id}", {__MODULE__, event, voice}) - voice - end - def publish(message, event, topic) when not is_nil(topic) do - PubSub.broadcast(Vyasa.PubSub, topic, {__MODULE__, event, message}) + msg = {__MODULE__, event, message} + PubSub.broadcast(Vyasa.PubSub, topic, msg) message end diff --git a/lib/vyasa/repo/filter.ex b/lib/vyasa/repo/filter.ex new file mode 100644 index 00000000..c5bafde5 --- /dev/null +++ b/lib/vyasa/repo/filter.ex @@ -0,0 +1,25 @@ +defmodule Vyasa.Repo.Filter do + import Ecto.Query + + defmacrop custom_where(binding, field, val, operator) do + {operator, [context: Elixir, import: Kernel], + [ + {:field, [], [binding, {:^, [], [field]}]}, + {:^, [], [val]} + ]} + end + + for op <- [:!=, :<, :<=, :==, :>, :>=, :ilike, :in, :like] do + + def where(query, {as, field}, unquote(op), value) do + query + |> where([{^as, x}], custom_where(x, field, value, unquote(op))) + end + + def where(query, field_name, unquote(op), value) do + query + |> where([o], custom_where(o, field_name, value, unquote(op))) + end + end + +end diff --git a/lib/vyasa/repo/paginated.ex b/lib/vyasa/repo/paginated.ex new file mode 100644 index 00000000..a5806a96 --- /dev/null +++ b/lib/vyasa/repo/paginated.ex @@ -0,0 +1,133 @@ +defmodule Vyasa.Repo.Paginated do + import Ecto.Query + alias Vyasa.Repo + + def query_builder(query, opts \\ []) + def query_builder(query, opts) do + sort_attribute = Keyword.get(opts, :sort_attribute, :inserted_at) + limit = Keyword.get(opts, :limit, 12) + ascending? = Keyword.get(opts, :asc, false) + filter = Keyword.get(opts, :filter, nil) + page = Keyword.get(opts, :page, nil) + + + query + |> maybe_ascend(sort_attribute, ascending?) + |> limit(^(limit + 1)) # last element not forwarded to client check downstream exists + |> maybe_filter(sort_attribute, ascending?, filter) + |> maybe_page(limit, page) + end + + def query_builder(query, page, attr, limit), do: query_builder(query, [sort_attribute: attr, limit: limit, page: page]) + + #named binding + defp maybe_ascend(query, {as, field}, false), do: from([{^as, x}] in query, order_by: [{:desc, field(x, ^field)}]) + defp maybe_ascend(query, {as, field}, true), do: from([{^as, x}] in query, order_by: [{:asc, field(x, ^field)}]) + defp maybe_ascend(query, attr, false), do: query |> order_by(desc: ^attr) + defp maybe_ascend(query, attr, true), do: query |> order_by(asc: ^attr) + defp maybe_ascend(query, _attr, nil), do: query + + defp maybe_filter(query, _attr, _ascending, nil), do: query + defp maybe_filter(query, attr, false, filter), do: query |> Repo.Filter.where(attr, :<, filter) + defp maybe_filter(query, attr, true, filter), do: query |> Repo.Filter.where(attr, :>, filter) + + defp maybe_page(query, _limit, nil), do: query + defp maybe_page(query, limit, page) when is_binary(page), do: maybe_page(query, limit, String.to_integer(page)) + defp maybe_page(query, limit, page), do: query |> offset(^(limit * (page - 1))) + + + def all(query, opts) when is_list(opts) do + limit = Keyword.get(opts, :limit, 12) + sort = case Keyword.get(opts, :sort_attribute, :inserted_at) do + {key, sort_attr} -> [key, sort_attr] + sort_attr -> [sort_attr] + end + + dao = query + |> query_builder(opts) + |> Repo.all() + + count = length(dao) + + case Keyword.fetch(opts, :page) do + # page-based + {:ok, page} -> + if Keyword.get(opts, :aggregate, true) do + total = Repo.aggregate(query, :count, List.last(sort)) + page_response(dao, page, total, limit) + else + page_response(dao, page, nil, limit) + end + + :error -> + cond do + count > limit -> + [ _ | [head| _] = resp ] = dao |> Enum.reverse() + %{data: resp |> Enum.reverse(), # remove last element + meta: %{ + pagination: %{ + downstream: true, + count: limit, + cursor: get_in(head, sort |> Enum.map(&Access.key(&1))) |> mutate_meta_attr}}} + + count != 0 -> + [head | _ ] = dao |> Enum.reverse() + %{data: dao, + meta: %{ + pagination: %{ + count: count, + downstream: false, + cursor: get_in(head, sort |> Enum.map(&Access.key(&1))) |> mutate_meta_attr}}} + + count == 0 -> + %{data: [], + meta: %{ + pagination: %{ + count: 0, + downstream: false, + cursor: Keyword.get(opts, :filter, nil) |> mutate_meta_attr + }}} + end + end + end + + def all(query, page, attr, limit), do: all(query, [sort_attribute: attr, limit: limit, page: page]) + + def page_response(dao, page, total, limit) when is_binary(page), do: page_response(dao, String.to_integer(page), total, limit) + def page_response(dao, page, total, limit) when is_binary(limit), do: page_response(dao, page, total, String.to_integer(limit)) + def page_response(dao, page, total, limit) do + count = length(dao) + if(count > limit) do + %{ + data: dao |> Enum.reverse() |> tl() |> Enum.reverse(), # remove last element + meta: %{ + pagination: %{ + downstream: true, + upstream: page > 1, + current: page, + total: total, + count: count, + start: (page - 1) * limit + 1 , + end: (page - 1) * limit + limit + }}} + else + %{ + data: dao, # remove last element + meta: %{ + pagination: %{ + downstream: false, + upstream: page > 1, + current: page, + count: count, + total: total, + start: (if count == 0, do: (page - 1) * limit + count, else: (page - 1) * limit + 1), + end: (page - 1) * limit + count + }}} + end + end + + + defp mutate_meta_attr(%DateTime{} = dt), do: DateTime.to_unix(dt, :second) + defp mutate_meta_attr(%NaiveDateTime{} = dt), do: NaiveDateTime.diff(dt, ~N[1970-01-01 00:00:00]) # seconds from unix time + defp mutate_meta_attr(attr), do: attr +end diff --git a/lib/vyasa/sangh.ex b/lib/vyasa/sangh.ex new file mode 100644 index 00000000..057d6231 --- /dev/null +++ b/lib/vyasa/sangh.ex @@ -0,0 +1,373 @@ +defmodule Vyasa.Sangh do + @moduledoc """ + The Sangh context. + """ + + import Ecto.Query, warn: false + import EctoLtree.Functions, only: [nlevel: 1] + alias Vyasa.Repo + alias Vyasa.Sangh.Comment + + + @doc """ + Returns the list of comments within a specific session. + + ## Examples + + iex> list_comments_by_session() + [%Comment{}, ...] + + """ + def list_comments_by_session(id) do + (from c in Comment, + where: c.session_id == ^id, + select: c) + |> Repo.all() + end + + @doc """ + Creates a comment. + + ## Examples + + iex> create_comment(%{field: new_value}) + {:ok, %Comment{}} + + iex> create_comment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + + def create_comment(attrs \\ %{}) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns a single comment. + + Raises `Ecto.NoResultsError` if the Comment does not exist. + + ## Examples + + iex> get_comment!(123) + %Comment{} + + iex> get_comment!(456) + ** (Ecto.NoResultsError) + + """ + + def get_comment!(id), do: Repo.get!(Comment, id) + + def get_comment(id) do + (from c in Comment, + where: c.id == ^id, + limit: 1) + |> Repo.one() + end + + + def get_descendents_comment(id) do + query = + from c in Comment, + as: :c, + where: c.parent_id == ^id, + order_by: [desc: c.inserted_at], + inner_lateral_join: sc in subquery( + from sc in Comment, + where: sc.parent_id == parent_as(:c).id, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + Repo.all(query) + end + + + def get_root_comments_by_comment(id) do + query = + from c in Comment, + as: :c, + where: c.comment_id == ^id, + where: nlevel(c.path) == 1, + preload: [:initiator], + order_by: [desc: c.inserted_at], + inner_lateral_join: sc in subquery( + from sc in Comment, + where: sc.parent_id == parent_as(:c).id, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + + Repo.all(query) + + end + + def get_descendents_comment(id, page) do + query = + from c in Comment, + as: :c, + where: c.parent_id == ^id, + preload: [:initiator], + inner_lateral_join: sc in subquery( + from sc in Comment, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + + Repo.Paginated.all(query, [page: page, asc: true]) + end + + def get_root_comments_by_session(id, page, sort_attribute \\ :inserted_at, limit \\ 12) do + query = + from c in Comment, + as: :c, + where: c.session_id == ^id, + where: nlevel(c.path) == 1, + inner_lateral_join: sc in subquery( + from sc in Comment, + where: sc.parent_id == parent_as(:c).id, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + + Repo.Paginated.all(query, page, sort_attribute, limit) + end + + + + def get_comments_by_session(id) do + query = Comment + |> where([c], c.session_id == ^id) + |> order_by(desc: :inserted_at) + + Repo.all(query) + end + + def get_comment_count_by_session(id) do + query = + Comment + |> where([e], e.session_id == ^id) + |> select([e], count(e)) + Repo.one(query) + end + + + # Gets child comments 1 level down only + def get_child_comments_by_session(id, path) do + path = path <> ".*{1}" + + query = + from c in Comment, + as: :c, + where: c.session_id == ^id, + where: fragment("? ~ ?", c.path, ^path), + preload: [:initiator], + inner_lateral_join: sc in subquery( + from sc in Comment, + where: sc.parent_id == parent_as(:c).id, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + + Repo.all(query) + end + + # Gets ancestors down up all levels only + # TODO: Get root comments together + def get_ancestor_comments_by_comment(comment_id, path) do + query = + from c in Comment, + as: :c, + where: c.comment_id == ^comment_id, + where: fragment("? @> ?", c.path, ^path), + preload: [:initiator], + inner_lateral_join: sc in subquery( + from sc in Comment, + where: sc.parent_id == parent_as(:c).id, + select: %{count: count()} + ), on: true, + select_merge: %{child_count: sc.count} + + Repo.all(query) + end + + # @doc """ + # Updates a comment. + + # ## Examples + + # iex> update_comment(comment, %{field: new_value}) + # {:ok, %Comment{}} + + # iex> update_comment(comment, %{field: bad_value}) + # {:error, %Ecto.Changeset{}} + + # """ + def update_comment(%Comment{} = comment, attrs) do + comment + |> Comment.mutate_changeset(attrs) + |> Repo.update() + end + + # @doc """ + # Updates a comment. + + # ## Examples + + # iex> update_comment!(%{field: value}) + # %Comment{} + + # iex> Need to Catch error state + + # """ + + def update_comment!(%Comment{} = comment, attrs) do + comment + |> Comment.mutate_changeset(attrs) + |> Repo.update!() + end + + # @doc """ + # Deletes a comment. + + # ## Examples + + # iex> delete_comment(comment) + # {:ok, %Comment{}} + + # iex> delete_comment(comment) + # {:error, %Ecto.Changeset{}} + + # """ + def delete_comment(%Comment{} = comment) do + Repo.delete(comment) + end + + # @doc """ + # Returns an `%Ecto.Changeset{}` for tracking comment changes. + + # ## Examples + + # iex> change_comment(comment) + # %Ecto.Changeset{data: %Comment{}} + + # """ + def change_comment(%Comment{} = comment, attrs \\ %{}) do + Comment.changeset(comment, attrs) + end + + def filter_root_comments_chrono(comments) do + comments + |> Enum.filter(&match?({{_}, _}, &1)) + |> sort_comments_chrono() + end + + def filter_child_comments_chrono(comments, comment) do + comments + |> Enum.filter(fn i -> elem(i, 1).parent_id == elem(comment, 1).id end) + |> sort_comments_chrono() + end + + defp sort_comments_chrono(comments) do + Enum.sort_by(comments, &elem(&1, 1).inserted_at, :desc) + end + + + alias Vyasa.Sangh.Session + + @doc """ + Returns the list of sessions. + + ## Examples + + iex> list_sessions() + [%Session{}, ...] + + """ + def list_sessions do + Repo.all(Session) + end + + @doc """ + Gets a single session. + + Raises `Ecto.NoResultsError` if the Session does not exist. + + ## Examples + + iex> get_session!(123) + %Session{} + + iex> get_session!(456) + ** (Ecto.NoResultsError) + + """ + def get_session!(id), do: Repo.get!(Session, id) + + @doc """ + Creates a session. + + ## Examples + + iex> create_session(%{field: value}) + {:ok, %Session{}} + + iex> create_session(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_session(attrs \\ %{}) do + %Session{} + |> Session.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a session. + + ## Examples + + iex> update_session(session, %{field: new_value}) + {:ok, %Session{}} + + iex> update_session(session, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_session(%Session{} = session, attrs) do + session + |> Session.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a session. + + ## Examples + + iex> delete_session(session) + {:ok, %Session{}} + + iex> delete_session(session) + {:error, %Ecto.Changeset{}} + + """ + def delete_session(%Session{} = session) do + Repo.delete(session) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking session changes. + + ## Examples + + iex> change_session(session) + %Ecto.Changeset{data: %Session{}} + + """ + def change_session(%Session{} = session, attrs \\ %{}) do + Session.changeset(session, attrs) + end +end diff --git a/lib/vyasa/sangh/comments.ex b/lib/vyasa/sangh/comments.ex new file mode 100644 index 00000000..c12c6ee7 --- /dev/null +++ b/lib/vyasa/sangh/comments.ex @@ -0,0 +1,37 @@ +defmodule Vyasa.Sangh.Comment do + use Ecto.Schema + import Ecto.Changeset + alias EctoLtree.LabelTree, as: Ltree + alias Vyasa.Sangh.{Comment, Session, Mark} + + @primary_key {:id, Ecto.UUID, autogenerate: false} + schema "comments" do + field :body, :string + field :active, :boolean, default: true + field :path, Ltree + field :signature, :string + field :child_count, :integer, default: 0, virtual: true + + belongs_to :session, Session, references: :id, type: Ecto.UUID + belongs_to :parent, Comment, references: :id, type: Ecto.UUID + + #has_many :marks, Mark, references: :id, foreign_key: :comment_bind_id, on_replace: :delete_if_exists + #has_many :bindings, Binding, references: :id, foreign_key: :comment_bind_id, on_replace: :delete_if_exists + + timestamps() + end + + @doc false + def changeset(%Comment{} = comment, attrs) do + comment + |> cast(attrs, [:id, :body, :path, :chapter_number, :p_type, :session_id, :parent_id, :text_id]) + |> cast_assoc(:bindings, with: &Mark.changeset/2) + |> validate_required([:id, :session_id]) + end + + def mutate_changeset(%Comment{} = comment, attrs) do + comment + |> cast(attrs, [:id, :body, :active]) + |> Map.put(:repo_opts, [on_conflict: {:replace_all_except, [:id]}, conflict_target: :id]) + end +end diff --git a/lib/vyasa/sangh/mark.ex b/lib/vyasa/sangh/mark.ex new file mode 100644 index 00000000..4ed68af8 --- /dev/null +++ b/lib/vyasa/sangh/mark.ex @@ -0,0 +1,30 @@ +defmodule Vyasa.Sangh.Mark do + @moduledoc """ + Interpretation & bounding of binding + """ + + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Sangh.{Comment} + alias Vyasa.Adapters.Binding + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "marks" do + field :body, :string + field :order, :integer + field :state, Ecto.Enum, values: [:draft, :bookmark, :live] + field :verse_id, :string, virtual: true + + belongs_to :comment, Comment, foreign_key: :comment_id, type: :binary_id + belongs_to :binding, Binding, foreign_key: :binding_id, type: :binary_id + + timestamps() + end + + def changeset(event, attrs) do + event + |> cast(attrs, [:body, :order, :status, :comment_id, :binding_id]) + end + +end diff --git a/lib/vyasa/sangh/session.ex b/lib/vyasa/sangh/session.ex new file mode 100644 index 00000000..eee306f4 --- /dev/null +++ b/lib/vyasa/sangh/session.ex @@ -0,0 +1,21 @@ +defmodule Vyasa.Sangh.Session do + use Ecto.Schema + import Ecto.Changeset + + alias Vyasa.Sangh.{Comment} + + @primary_key {:id, Ecto.UUID, autogenerate: true} + schema "sessions" do + + has_many :comments, Comment, references: :id, foreign_key: :session_id, on_replace: :delete_if_exists + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(session, attrs) do + session + |> cast(attrs, [:id]) + |> validate_required([:id]) + end +end diff --git a/lib/vyasa/written.ex b/lib/vyasa/written.ex index d9662a09..577d8e96 100644 --- a/lib/vyasa/written.ex +++ b/lib/vyasa/written.ex @@ -18,12 +18,12 @@ defmodule Vyasa.Written do """ defguard is_uuid?(value) - when is_bitstring(value) and - byte_size(value) == 36 and - binary_part(value, 8, 1) == "-" and - binary_part(value, 13, 1) == "-" and - binary_part(value, 18, 1) == "-" and - binary_part(value, 23, 1) == "-" + when is_bitstring(value) and + byte_size(value) == 36 and + binary_part(value, 8, 1) == "-" and + binary_part(value, 13, 1) == "-" and + binary_part(value, 18, 1) == "-" and + binary_part(value, 23, 1) == "-" @doc """ Returns the list of texts. @@ -80,8 +80,6 @@ defmodule Vyasa.Written do |> Repo.preload([:verses]) end - - @doc """ Gets a single text. @@ -98,6 +96,8 @@ defmodule Vyasa.Written do """ def get_text!(id), do: Repo.get!(Text, id) + def get_text_by_title!(title), do: Repo.get_by!(Text, title: title) + @doc """ Gets a single source by id. @@ -112,46 +112,60 @@ defmodule Vyasa.Written do ** (Ecto.NoResultsError) """ - def get_source!(id), do: Repo.get!(Source, id) - |> Repo.preload([:chapters, :verses]) + def get_source!(id), + do: + Repo.get!(Source, id) + |> Repo.preload([:chapters, :verses]) def get_source_by_title(title) do - query = from src in Source, - where: src.title == ^title, - preload: [verses: [:translations], chapters: [:translations]] + query = + from src in Source, + where: src.title == ^title, + preload: [verses: [:translations], chapters: [:translations]] Repo.one(query) end def get_chapters_by_src(src_title) do - (from c in Chapter, + from(c in Chapter, inner_join: src in assoc(c, :source), where: src.title == ^src_title, inner_join: t in assoc(c, :translations), - on: t.source_id == src.id) + on: t.source_id == src.id + ) |> select_merge([c, src, t], %{ - c | translations: [t], source: src - }) + c + | translations: [t], + source: src + }) |> Repo.all() end def get_chapter(no, source_title) do - (from c in Chapter, where: c.no == ^no, + from(c in Chapter, + where: c.no == ^no, inner_join: src in assoc(c, :source), - where: src.title == ^source_title) + where: src.title == ^source_title + ) |> Repo.one() end def get_chapter(no, sid, lang) when is_uuid?(sid) do - - target_lang = (from ts in Translation, - where: ts.lang == ^lang and ts.source_id == ^sid) - - (from c in Chapter, - where: c.no == ^no and c.source_id == ^sid, - preload: [verses: ^(from v in Verse, where: v.source_id == ^sid, order_by: v.no, - preload: [translations: ^target_lang]), - translations: ^target_lang] + target_lang = + from ts in Translation, + where: ts.lang == ^lang and ts.source_id == ^sid + + from(c in Chapter, + where: c.no == ^no and c.source_id == ^sid, + preload: [ + verses: + ^from(v in Verse, + where: v.source_id == ^sid, + order_by: v.no, + preload: [translations: ^target_lang] + ), + translations: ^target_lang + ] ) |> Repo.one() end @@ -159,25 +173,33 @@ defmodule Vyasa.Written do def get_chapter(no, source_title, lang) do %Source{id: id} = _src = get_source_by_title(source_title) - target_lang = (from ts in Translation, - where: ts.lang == ^lang and ts.source_id == ^id) - - (from c in Chapter, - where: c.no == ^no and c.source_id == ^id, - preload: [verses: ^(from v in Verse, where: v.source_id == ^id, order_by: v.no, - preload: [translations: ^target_lang]), - translations: ^target_lang] + target_lang = + from ts in Translation, + where: ts.lang == ^lang and ts.source_id == ^id + + from(c in Chapter, + where: c.no == ^no and c.source_id == ^id, + preload: [ + verses: + ^from(v in Verse, + where: v.source_id == ^id, + order_by: v.no, + preload: [translations: ^target_lang] + ), + translations: ^target_lang + ] ) |> Repo.one() end def get_verses_in_chapter(no, source_id) do - query_verse = from v in Verse, - where: v.chapter_no == ^no and v.source_id == ^source_id, - preload: [:chapter] + query_verse = + from v in Verse, + where: v.chapter_no == ^no and v.source_id == ^source_id, + preload: [:chapter] Repo.all(query_verse) - end + end @doc """ Creates a text. @@ -304,4 +326,4 @@ defmodule Vyasa.Written do def change_source(%Source{} = source, attrs \\ %{}) do Source.mutate_changeset(source, attrs) end - end +end diff --git a/lib/vyasa/written/verse.ex b/lib/vyasa/written/verse.ex index 364eed45..b6f63650 100644 --- a/lib/vyasa/written/verse.ex +++ b/lib/vyasa/written/verse.ex @@ -3,15 +3,20 @@ defmodule Vyasa.Written.Verse do import Ecto.Changeset alias Vyasa.Written.{Source, Chapter, Translation} + alias Vyasa.Sangh.{Comment} + alias Vyasa.Adapters.Binding @primary_key {:id, Ecto.UUID, autogenerate: true} schema "verses" do field :no, :integer field :body, :string + field :binding, :any, virtual: true belongs_to :source, Source, type: Ecto.UUID belongs_to :chapter, Chapter, type: :integer, references: :no, foreign_key: :chapter_no has_many :translations, Translation + + many_to_many :comments, Comment, join_through: Binding end @doc false diff --git a/lib/vyasa_web/components/audio_player.ex b/lib/vyasa_web/components/audio_player.ex index 05418e65..28fda934 100644 --- a/lib/vyasa_web/components/audio_player.ex +++ b/lib/vyasa_web/components/audio_player.ex @@ -1,38 +1,44 @@ defmodule VyasaWeb.AudioPlayer do - use VyasaWeb, :live_component + @moduledoc """ + This is the concrete AudioPlayer module that interfaces with the html5 audio player. + User-generated events will get piped directly to the MediaBridge which will notify the AudioPlayer when there are updates to make. + Any interfacing with the html5 player shall happen from this module (e.g. dispatching an evnent for the client-side AudioPlayer Hook). + """ + use VyasaWeb, :live_component - def mount(_, _, socket) do - socket - |> assign(playback: nil) - end + alias Vyasa.Medium.{Playback} - @impl true - def render(assigns) do - ~H""" -
- -
- """ - end + def mount(_, _, socket) do + socket + |> assign(playback: nil) + end - @impl true - def update(%{ - event: "media_bridge:update_audio_player" = event, - playback: playback, - } = _assigns, socket) do - IO.inspect("handle update case in audio_player.ex with event = #{event}", label: "checkpoint") + @impl true + def render(assigns) do + ~H""" +
+ +
+ """ + end - { - :ok, socket - |> assign(playback: playback) - } - end + @impl true + def update( + %{ + event: "media_bridge:notify_audio_player" = _event, + playback: %Playback{} = playback + } = _assigns, + socket + ) do + {:ok, + socket + |> assign(playback: playback)} + end - @impl true - def update(assigns, socket) do - IO.inspect(assigns, label: "what") - {:ok, socket - |> assign(playback: nil) - } - end + @impl true + def update(_assigns, socket) do + {:ok, + socket + |> assign(playback: nil)} end +end diff --git a/lib/vyasa_web/components/control_panel.ex b/lib/vyasa_web/components/control_panel.ex new file mode 100644 index 00000000..0eefb032 --- /dev/null +++ b/lib/vyasa_web/components/control_panel.ex @@ -0,0 +1,79 @@ +defmodule VyasaWeb.ControlPanel do + @moduledoc """ + The ControlPanel is the hover-overlay of buttons that allow the user to access + usage-modes and carry out actions related to a specific mode. + """ + use VyasaWeb, :live_component + alias Phoenix.LiveView.Socket + + def mount(_, _, socket) do + socket + end + + @impl true + def render(assigns) do + ~H""" +
+ + <%= @mode.mode %> + <.button + id="toggleButton" + class="bg-blue-500 text-white p-2 rounded-full focus:outline-none" + phx-click={JS.push("toggle_show_control_panel")} + phx-target={@myself} + > + <.icon name={@mode.mode_icon_name} /> + +
+ <.button + phx-click={JS.push("change_mode", value: %{current_mode: @mode.mode, target_mode: "read"})} + class="bg-green-500 text-white px-4 py-2 rounded-md focus:outline-none" + > + Change to Read + + <.button + phx-click={JS.push("change_mode", value: %{current_mode: @mode.mode, target_mode: "draft"})} + class="bg-red-500 text-white px-4 py-2 rounded-md focus:outline-none" + > + Change to Draft + +
+
+ """ + end + + @impl true + def handle_event( + "toggle_show_control_panel", + _params, + %Socket{ + assigns: + %{ + show_control_panel?: show_control_panel? + } = _assigns + } = socket + ) do + IO.inspect(show_control_panel?, label: "TRACE handle event for toggle_show_control_panel") + + { + :noreply, + socket + |> assign(show_control_panel?: !show_control_panel?) + } + end + + @impl true + def update(%{id: _id, mode: mode} = _assigns, socket) do + {:ok, + socket + |> assign(show_control_panel?: false) + |> assign(mode: mode)} + end +end diff --git a/lib/vyasa_web/components/core_components.ex b/lib/vyasa_web/components/core_components.ex index 37083c7a..2994d852 100644 --- a/lib/vyasa_web/components/core_components.ex +++ b/lib/vyasa_web/components/core_components.ex @@ -89,6 +89,117 @@ defmodule VyasaWeb.CoreComponents do """ end + attr(:id, :string, required: true) + attr(:show, :boolean, default: false) + attr(:on_cancel, JS, default: %JS{}, doc: "JS cancel action") + attr(:on_confirm, JS, default: %JS{}, doc: "JS confirm action") + attr(:background, :string, default: "bg-white") + attr(:close_button, :boolean, default: true) + attr(:main_width, :string, default: "w-full") + + slot(:inner_block, required: true) + + slot(:confirm) do + attr(:tone, :atom) + end + + slot(:cancel) + + def modal_wrapper(assigns) do + ~H""" +