commit 76

This commit is contained in:
Moritz Ruth 2025-03-13 00:48:21 +01:00
parent b90f897123
commit 18dc17d6d5
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
46 changed files with 2032 additions and 2096 deletions

3
ui/.gitignore vendored
View file

@ -1,4 +1,5 @@
.idea/
node_modules/
*.env
dist/
src/generated-types
*.env

View file

@ -1 +0,0 @@
18

View file

@ -1,13 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Moira</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="./node_modules/@fontsource-variable/manrope/index.css" rel="stylesheet"/>
<script type="module" src="./src/main.ts"></script>
</head>
<body>
<div id="app"></div>
</body>
<head>
<meta charset="UTF-8">
<title>Janus</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="./node_modules/@fontsource-variable/manrope/index.css" rel="stylesheet"/>
<script type="module" src="./src/main.ts"></script>
<link rel="stylesheet" href="src/global.scss"/>
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -1,31 +1,35 @@
{
"name": "moira",
"name": "janus-ui",
"private": true,
"version": "1.0.0",
"author": "Moritz Ruth <dev@moritzruth.de>",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite --port 3000 --host",
"build": "vite build --emptyOutDir --outDir ../src/main/resources/ui"
},
"devDependencies": {
"@iconify-json/ph": "^1.1.5",
"@types/lodash-es": "^4.17.7",
"@vitejs/plugin-vue": "^4.2.3",
"typescript": "^5.0.4",
"unplugin-icons": "^0.16.1",
"vite": "^4.3.9",
"vite-plugin-pages": "^0.30.1",
"vite-plugin-windicss": "^1.9.0",
"windicss": "^3.5.6"
"@iconify-json/ph": "^1.2.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.10",
"@unocss/preset-wind3": "^66.1.0-beta.3",
"unocss": "^66.1.0-beta.3",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.8.2",
"unplugin-icons": "^22.1.0",
"unplugin-vue-router": "^0.12.0",
"vite": "^6.2.1"
},
"dependencies": {
"@fontsource-variable/manrope": "^5.0.0",
"@vueuse/core": "^10.1.2",
"@fontsource-variable/manrope": "^5.2.5",
"@vueuse/core": "^13.0.0",
"lodash-es": "^4.17.21",
"modern-normalize": "^3.0.1",
"reconnecting-websocket": "^4.4.0",
"sass": "^1.62.1",
"vue": "^3.3.4",
"vue-router": "^4.2.1"
"sass": "^1.85.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"pnpm": {
"onlyBuiltDependencies": [

2016
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,39 @@
<template>
<div id="app" class="bg-black text-white">
<div class="flex flex-col justify-center items-center h-full space-y-4" v-if="isConnecting">
<div class="font-bold text-10">Connecting...</div>
<div class="text-s1">Created by Moritz Ruth</div>
</div>
<router-view v-else/>
<TimeDisplay/>
<div v-if="isConnecting" class="flex flex-col justify-center items-center h-100dvh gap-4">
<div class="font-bold text-10">Connecting</div>
<div class="text-s1">Janus, created by Moritz Ruth</div>
</div>
<div v-else class="h-100dvh flex flex-col">
<div class="font-black text-2xl md:text-4xl p-4 md:p-8 pb-0 md:pb-0 flex-shrink-0">
{{ current.act === null ? "" : `${current.act.name}` }}{{ current.scene.name }}
</div>
<div class="flex-grow p-4 md:p-8 overflow-hidden">
<router-view/>
</div>
<MusicProgressBar class="h-10 flex-shrink-0"/>
</div>
<TimeDisplay/>
</template>
<style lang="scss">
html, body, #app {
width: 100vw;
height: 100vh;
overflow: hidden;
font-family: "Manrope Variable", sans-serif;
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
::-webkit-scrollbar {
width: 10px;
.list-leave-to {
opacity: 0;
transform: translateX(10px);
}
::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 10%);
.list-enter-from {
opacity: 0;
transform: translateX(-10px);
}
&:hover {
background: rgb(255 255 255 / 20%);
}
.list-leave-active {
position: absolute;
}
</style>
@ -34,10 +41,17 @@
import { connect } from "./syncing"
import { ref } from "vue"
import TimeDisplay from "./components/TimeDisplay.vue"
import { current } from "@/state.js"
import MusicProgressBar from "@/components/MusicProgressBar.vue"
export default {
name: "App",
components: { TimeDisplay },
computed: {
current() {
return current
}
},
components: { MusicProgressBar, TimeDisplay },
setup() {
const isConnecting = ref(true)
connect().then(() => {

View file

@ -1,13 +1,15 @@
<template>
<div class="p-2 space-y-8">
<div>
<div class="pb-2 font-bold text-gray-400 text-2 tracking-wider uppercase">
auf der Bühne
<div class="overflow-y-auto">
<transition-group name="list" tag="div">
<div v-if="current.step.actorsOnStage.length === 0" key="" class="text-gray-400">Niemand</div>
<div
v-for="actor in current.step.actorsOnStage"
:key="parseStringWithDetails(actor).main"
class="truncate text-4.5"
>
{{ actor }}
</div>
<div>
<EntrancesList :entrances="current.step.actorsOnStage"/>
</div>
</div>
</transition-group>
</div>
</template>
@ -16,8 +18,5 @@
</style>
<script setup lang="ts">
import EntrancesList from "./EntrancesList.vue"
import { current, getNextValidPosition, getStep, parseStringWithDetails, ShowPosition, state, Step, show } from "../state"
import { computed } from "vue"
import { intersection } from "lodash-es"
import { current, parseStringWithDetails } from "@/state"
</script>

View file

@ -1,23 +1,27 @@
<template>
<button
class="px-5 py-2 active:bg-green-800 transition duration-200 font-bold text-5"
:class="[$style.root, isActive ? 'bg-green-800' : 'bg-green-600']"
@click="e => emit('click', e)"
>
<slot/>
</button>
<button
:class="[$style.root, isActive ? 'bg-green-800' : 'bg-green-600']"
class="px-5 py-2 active:bg-green-800 transition duration-200 font-bold text-4"
@click="e => emit('click', e)"
>
<slot/>
</button>
</template>
<style module>
.root {
user-select: none;
}
.root {
user-select: none;
}
</style>
<script setup lang="ts">
const props = defineProps<{
isActive?: boolean
}>()
<script lang="ts" setup>
const props = defineProps<{
isActive?: boolean
}>()
const emit = defineEmits(["click"])
const emit = defineEmits<{
click: [MouseEvent]
pointerdown: [PointerEvent]
pointerup: [PointerEvent]
}>()
</script>

View file

@ -1,43 +1,38 @@
<template>
<div
class="flex flex-col justify-center py-4"
:class="$style.root"
:data-blinking="isBlinking"
>
<slot/>
</div>
<div :class="$style.root" :data-blinking="isBlinking">
<slot/>
</div>
</template>
<style module>
.root[data-blinking="true"] {
animation: alternate infinite 1000ms ease-in-out pulse;
}
.root[data-blinking="true"] {
animation: alternate infinite 1000ms ease-in-out pulse;
}
@keyframes pulse {
from {
@apply bg-red-900;
}
@keyframes pulse {
from {
@apply bg-red-900;
}
to {
@apply bg-transparent;
}
}
to {
@apply bg-transparent;
}
}
</style>
<script setup lang="ts">
import { toRef, watch } from "vue"
import { autoResetRef } from "@vueuse/core"
import { computed } from "vue"
<script lang="ts" setup>
import { computed, toRef, watch } from "vue"
import { autoResetRef } from "@vueuse/core"
const props = defineProps<{
value: unknown
blinkSeconds: number
}>()
const props = defineProps<{
value: unknown
blinkSeconds: number
}>()
const value = toRef(props, "value")
const isBlinking = autoResetRef(false, computed(() => props.blinkSeconds * 1000))
const value = toRef(props, "value")
const isBlinking = autoResetRef(false, computed(() => props.blinkSeconds * 1000))
watch(value, () => {
isBlinking.value = true
})
watch(value, () => {
isBlinking.value = true
})
</script>

View file

@ -1,82 +1,83 @@
<template>
<div class="flex items-start space-x-2" :class="textClass ?? 'text-4'">
<component :is="icon" class="mt-0.5 flex-shrink-0"/>
<div :class="textClass ?? 'text-4'" class="flex items-start gap-2 py-0.5">
<component :is="icon" class="flex-shrink-0"/>
<div :class="singleLine && 'truncate'">
{{ text }}
</div>
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import MusicNoteIcon from "virtual:icons/ph/music-note"
import ChatCircleTextIcon from "virtual:icons/ph/chat-circle-TEXT"
import StopIcon from "virtual:icons/ph/stop"
import HeadlightsIcon from "virtual:icons/ph/headlights"
import WarningIcon from "virtual:icons/ph/warning"
import ArrowsOutLineHorizontalIcon from "virtual:icons/ph/arrows-out-line-horizontal"
import ArrowsInLineHorizontalIcon from "virtual:icons/ph/arrows-in-line-horizontal"
import DotFillIcon from "virtual:icons/ph/dot-fill"
import { computed } from "vue"
import { START_STEP, Step } from "../state"
import { formatSeconds } from "../helpers"
import { START_STEP, Step } from "@/state"
import { formatSeconds } from "@/helpers"
import { isEqual } from "lodash-es"
const props = defineProps<{
step: Step,
singleLine?: boolean
textClass?: string
step: Step,
singleLine?: boolean
textClass?: string
}>()
const icon = computed(() => {
const cue = props.step.cue
const cue = props.step.cue
if (isEqual(props.step.position, START_STEP.position)) {
return DotFillIcon
}
if (isEqual(props.step.position, START_STEP.position)) {
return DotFillIcon
}
switch (cue.type) {
case "CURTAIN":
return cue.state === "closed"
? ArrowsInLineHorizontalIcon
: ArrowsOutLineHorizontalIcon
switch (cue.type) {
case "CURTAIN":
return cue.state === "closed"
? ArrowsInLineHorizontalIcon
: ArrowsOutLineHorizontalIcon
case "LIGHT": return HeadlightsIcon
case "TEXT": return ChatCircleTextIcon
case "MUSIC_START": return MusicNoteIcon
case "MUSIC_END": return StopIcon
case "CUSTOM": return WarningIcon
}
case "TEXT":
return ChatCircleTextIcon
case "MUSIC_START":
return MusicNoteIcon
case "MUSIC_END":
return StopIcon
case "CUSTOM":
return WarningIcon
}
})
const text = computed(() => {
const cue = props.step.cue
const cue = props.step.cue
switch (cue.type) {
case "CURTAIN":
if (cue.state === "open") {
return cue.whileMoving ? "Der Vorhang öffnet sich" : "Der Vorhang ist geöffnet"
} else {
return cue.whileMoving ? "Der Vorhang schließt sich" : "Der Vorhang ist geschlossen"
}
switch (cue.type) {
case "CURTAIN":
if (cue.state === "open") {
return cue.whileMoving ? "Der Vorhang öffnet sich" : "Der Vorhang ist geöffnet"
} else {
return cue.whileMoving ? "Der Vorhang schließt sich" : "Der Vorhang ist geschlossen"
}
case "LIGHT":
if (cue.state === "on") {
return cue.whileFading ? "Das Licht geht an" : "Das Licht ist an"
} else {
return cue.whileFading ? "Das Licht geht aus" : "Das Licht ist aus"
}
case "TEXT":
let suffix = ""
if (cue.clarification !== undefined) suffix = ` (${cue.clarification})`
case "TEXT":
let suffix = ""
if (cue.clarification !== undefined) {
suffix = ` (${cue.clarification})`
}
return `${cue.speaker}: »${cue.text}«${suffix}`
return `${cue.speaker}: »${cue.text}«${suffix}`
case "MUSIC_START":
return `${cue.title} [${formatSeconds(cue.duration / 1000)}]`
case "MUSIC_START": return `${cue.title} [${formatSeconds(cue.duration / 1000)}]`
case "MUSIC_END": return "Ende der Musik"
case "CUSTOM": return cue.text
}
case "MUSIC_END":
return "Ende der Musik"
case "CUSTOM":
return cue.text
}
})
</script>

View file

@ -1,7 +1,8 @@
<template>
<div>
<Button :is-active="!state.isLightBehindCurtainOn" @click="onClick()">{{ state.isLightBehindCurtainOn ? "Umbaulicht ist AN" : "Umbaulicht ist AUS" }}</Button>
</div>
<Button class="w-20" :is-active="!state.isLightBehindCurtainOn" @click="onClick()">{{
state.isLightBehindCurtainOn ? "AN" : "AUS"
}}
</Button>
</template>
<style module>
@ -9,16 +10,16 @@
</style>
<script setup lang="ts">
import { state } from "../state"
import Button from "./Button.vue"
import { state } from "@/state"
import Button from "./Button.vue"
function onClick() {
fetch("/api/lightBehindCurtain", {
method: "POST",
body: JSON.stringify(!state.isLightBehindCurtainOn),
headers: {
"Content-Type": "application/json"
}
})
}
function onClick() {
fetch("/api/lightBehindCurtain", {
method: "POST",
body: JSON.stringify(!state.isLightBehindCurtainOn),
headers: {
"Content-Type": "application/json"
}
})
}
</script>

View file

@ -1,54 +0,0 @@
<template>
<transition-group tag="div" name="list">
<div
v-for="actor in entrances"
:key="parseStringWithDetails(actor).main"
class="truncate"
>
<span class="font-bold">
{{ parseStringWithDetails(actor).main }}
</span>
<span class="text-gray-400 pl-2">
{{ parseStringWithDetails(actor).details }}
</span>
</div>
</transition-group>
</template>
<style scoped>
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-leave-to {
opacity: 0;
transform: translateX(10px);
}
.list-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.list-leave-active {
position: absolute;
}
</style>
<script lang="ts">
import { PropType } from "vue"
import { parseStringWithDetails } from "../state"
export default {
name: "EntrancesList",
methods: { parseStringWithDetails },
props: {
entrances: {
type: Array as PropType<string[]>,
required: true
}
}
}
</script>

View file

@ -1,86 +1,85 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<span class="-sm:hidden">Nebel:</span>
<Button
v-for="p in buttonPowers"
@pointerdown="e => onButtonActive(e, p[0])"
@pointerup="e => onButtonInactive(e)"
:is-active="isActive && p[0] === power"
>
{{ p[0] * 100 }}%
</Button>
</div>
<div class="flex items-center gap-1">
<Button
v-for="p in buttonPowers"
:is-active="isActive && p[0] === power"
@pointerdown="e => onButtonActive(e, p[0])"
@pointerup="e => onButtonInactive(e)"
>
{{ p[0] * 100 }}%
</Button>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import Button from "./Button.vue"
import { computed, ref, watch } from "vue"
import { onKeyDown, onKeyUp, useEventListener, useIntervalFn } from "@vueuse/core"
<script lang="ts" setup>
import Button from "./Button.vue"
import { computed, ref, watch } from "vue"
import { onKeyDown, onKeyUp, useEventListener, useIntervalFn } from "@vueuse/core"
const activation = ref<"button" | "key" | null>(null)
const isActive = computed(() => activation.value !== null)
const power = ref(0)
const activation = ref<"button" | "key" | null>(null)
const isActive = computed(() => activation.value !== null)
const power = ref(0)
function onButtonActive(e: PointerEvent, p: number) {
// @ts-ignore
e.target.setPointerCapture(e.pointerId)
activation.value = "button"
power.value = p
}
function onButtonActive(e: PointerEvent, p: number) {
// @ts-ignore
e.target.setPointerCapture(e.pointerId)
activation.value = "button"
power.value = p
}
function onButtonInactive(e: PointerEvent) {
// @ts-ignore
e.target.releasePointerCapture(e.pointerId)
activation.value = null
power.value = 0
}
function onButtonInactive(e: PointerEvent) {
// @ts-ignore
e.target.releasePointerCapture(e.pointerId)
activation.value = null
power.value = 0
}
useEventListener(document.body, "pointerup", () => {
if (activation.value === "button") {
activation.value = null
power.value = 0
}
})
useEventListener(document.body, "pointerup", () => {
if (activation.value === "button") {
activation.value = null
power.value = 0
}
})
const buttonPowers: Array<[number, string]> = [[0.1, "7"], [0.25, "8"], [0.5, "9"], [1, "0"]]
const buttonPowers: Array<[number, string]> = [[0.1, "7"], [0.25, "8"], [0.5, "9"], [1, "0"]]
for (const p of buttonPowers) {
onKeyDown(p[1], () => {
activation.value = "key"
power.value = p[0]
})
for (const p of buttonPowers) {
onKeyDown(p[1], () => {
activation.value = "key"
power.value = p[0]
})
onKeyUp(p[1], () => {
if (activation.value === "key" && power.value === p[0]) {
activation.value = null
power.value = 0
}
})
}
onKeyUp(p[1], () => {
if (activation.value === "key" && power.value === p[0]) {
activation.value = null
power.value = 0
}
})
}
function send() {
fetch("/api/fog", {
method: "POST",
body: JSON.stringify(power.value),
headers: {
"Content-Type": "application/json"
}
})
}
function send() {
fetch("/api/fog", {
method: "POST",
body: JSON.stringify(power.value),
headers: {
"Content-Type": "application/json"
}
})
}
const sendTimer = useIntervalFn(send, 200, { immediate: false })
const sendTimer = useIntervalFn(send, 200, { immediate: false })
watch(power, () => {
if (activation.value === null) {
sendTimer.pause()
send()
} else {
send()
sendTimer.resume()
}
})
watch(power, () => {
if (activation.value === null) {
sendTimer.pause()
send()
} else {
send()
sendTimer.resume()
}
})
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="overflow-hidden text-6 box p-4" :data-blinking="isBlinking">
<div :data-blinking="isBlinking" class="overflow-hidden text-6 box p-4">
{{ message }}
</div>
</template>

View file

@ -1,23 +1,19 @@
<template>
<div class="h-full max-h-60">
<div class="max-h-60">
<textarea
:value="state.message"
id="message-box"
:class="$style.area"
class="h-full w-full border border-dark-200 focus:border-green-500 focus:outline-none transition rounded-lg bg-dark-800 p-4 text-3"
placeholder="Nachricht an alle"
@input="e => setMessage(e.target.value)"
:value="state.message"
class="h-full w-full border border-dark-200 focus:border-green-500 focus:outline-none transition rounded-lg bg-dark-800 p-4 text-5 resize-none"
placeholder="Nachricht an alle"
@input="e => setMessage((e.target as HTMLInputElement).value)"
/>
</div>
</template>
<style module>
.area {
resize: none;
}
</style>
<script setup lang="ts">
import { state } from "../state"
import { setMessage } from "../syncing"
import { state } from "@/state"
import { setMessage } from "@/syncing"
</script>

View file

@ -1,18 +1,18 @@
<template>
<div class="flex flex-col space-y-5" :class="scrollable ? 'overflow-y-auto' : 'overflow-hidden'">
<div :class="scrollable ? 'overflow-y-auto' : 'overflow-hidden'" class="flex flex-col space-y-5">
<div v-for="(act, actIndex) in show.acts">
<MotionsListAct :act="act" :center-current="Boolean(centerCurrent)"/>
<MotionsListAct :act="act" :center-current="Boolean(centerCurrent)"/>
</div>
<div class="h-[50%] flex-shrink-0"/>
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import MotionsListAct from "./MotionsListAct.vue"
import { show } from "../state"
const props = defineProps<{
centerCurrent?: boolean
scrollable?: boolean
centerCurrent?: boolean
scrollable?: boolean
}>()
</script>

View file

@ -1,83 +1,83 @@
<template>
<div class="pt-2">
<div class="font-bold text-7 flex gap-5 items-center pb-4">
<span class="flex-grow h-2px bg-white"></span>
<span>{{ act.name }}</span>
<span class="flex-grow h-2px bg-white"></span>
</div>
<div class="flex flex-col gap-2">
<div
v-for="(scene, sceneIndex) in scenes"
:key="sceneIndex"
>
<div class="text-gray-400 pl-3 text-5">
{{ scene.name }}
</div>
<div class="flex flex-col">
<template v-for="step in scene.steps" :key="step.position">
<MotionsListStep
:step="step"
:more-positions="step.morePositions"
:center-current="centerCurrent"
/>
</template>
</div>
</div>
</div>
</div>
<div class="pt-2">
<div class="font-bold text-7 flex gap-5 items-center pb-4">
<span class="flex-grow h-2px bg-white"></span>
<span>{{ act.name }}</span>
<span class="flex-grow h-2px bg-white"></span>
</div>
<div class="flex flex-col gap-2">
<div
v-for="(scene, sceneIndex) in scenes"
:key="sceneIndex"
>
<div class="text-gray-400 pl-3 text-5">
{{ scene.name }}
</div>
<div class="flex flex-col">
<template v-for="step in scene.steps" :key="step.position">
<MotionsListStep
:center-current="centerCurrent"
:more-positions="step.morePositions"
:step="step"
/>
</template>
</div>
</div>
</div>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import { Act, Scene, ShowPosition, Step } from "../state"
import { computed } from "vue"
import MotionsListStep from "./MotionsListStep.vue"
<script lang="ts" setup>
import { Act, Scene, ShowPosition, Step } from "../state"
import { computed } from "vue"
import MotionsListStep from "./MotionsListStep.vue"
const props = defineProps<{
act: Act
centerCurrent: boolean
}>()
const props = defineProps<{
act: Act
centerCurrent: boolean
}>()
interface MotionStep extends Step {
morePositions: ShowPosition[]
}
interface MotionStep extends Step {
morePositions: ShowPosition[]
}
interface MotionScene extends Scene {
steps: MotionStep[]
}
interface MotionScene extends Scene {
steps: MotionStep[]
}
const scenes = computed(() => {
const all = props.act.scenes
const result: MotionScene[] = []
const scenes = computed(() => {
const all = props.act.scenes
const result: MotionScene[] = []
for (const scene of all) {
const steps: MotionStep[] = []
let accStep: MotionStep | null = null
for (const scene of all) {
const steps: MotionStep[] = []
let accStep: MotionStep | null = null
for (const step of scene.steps) {
if ((step.position.step === 0) || step.actorEntrances.length > 0 || step.actorExits.length > 0) {
if (accStep !== null) steps.push(accStep)
for (const step of scene.steps) {
if ((step.position.step === 0) || step.actorEntrances.length > 0 || step.actorExits.length > 0) {
if (accStep !== null) steps.push(accStep)
accStep = {
...step,
morePositions: []
}
} else {
if (accStep !== null) accStep.morePositions.push(step.position)
}
}
accStep = {
...step,
morePositions: []
}
} else {
if (accStep !== null) accStep.morePositions.push(step.position)
}
}
if (accStep !== null) steps.push(accStep)
if (accStep !== null) steps.push(accStep)
if (steps.length > 0) result.push({
...scene,
steps: steps
})
}
if (steps.length > 0) result.push({
...scene,
steps: steps
})
}
return result
})
return result
})
</script>

View file

@ -1,80 +1,80 @@
<template>
<div class="transition p-3" :class="isActive && 'bg-green-800'">
<div class="flex space-x-2">
<div class="flex-grow">
<CueBox text-class="text-6" :step="step"/>
<div v-if="step.actorEntrances.length + step.actorExits.length > 0" class="py-2 pl-8 space-y-2 text-7">
<div class="flex flex-col space-y-1">
<div
v-for="motion in step.actorEntrances"
:key="motion"
class="flex items-center"
>
<CaretDoubleRightIcon/>
<div class="pl-2">
<div :class="isActive && 'bg-green-800'" class="transition p-3">
<div class="flex space-x-2">
<div class="flex-grow">
<CueBox :step="step" text-class="text-6"/>
<div v-if="step.actorEntrances.length + step.actorExits.length > 0" class="py-2 pl-8 space-y-2 text-7">
<div class="flex flex-col space-y-1">
<div
v-for="motion in step.actorEntrances"
:key="motion"
class="flex items-center"
>
<CaretDoubleRightIcon/>
<div class="pl-2">
<span class="font-bold">
{{ parseStringWithDetails(motion).main }}
</span>
<span class="pl-1.5">
<span class="pl-1.5">
{{ parseStringWithDetails(motion).details }}
</span>
</div>
</div>
<div
v-for="motion in step.actorExits"
:key="motion"
class="flex items-center"
>
<CaretDoubleLeftIcon/>
<div class="pl-2">
</div>
</div>
<div
v-for="motion in step.actorExits"
:key="motion"
class="flex items-center"
>
<CaretDoubleLeftIcon/>
<div class="pl-2">
<span class="font-bold">
{{ parseStringWithDetails(motion).main }}
</span>
<span class="pl-1.5">
<span class="pl-1.5">
{{ parseStringWithDetails(motion).details }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="step.hasChangedProps" class="flex space-x-2 pt-0.5 text-6">
<BarricadeIcon class="mt-0.5"/>
<span>Umbau</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="step.hasChangedProps" class="flex space-x-2 pt-0.5 text-6">
<BarricadeIcon class="mt-0.5"/>
<span>Umbau</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { parseStringWithDetails, ShowPosition, START_STEP, state, Step } from "../state"
import CueBox from "./CueBox.vue"
import CaretDoubleRightIcon from "virtual:icons/ph/caret-double-right"
import CaretDoubleLeftIcon from "virtual:icons/ph/caret-double-left"
import BarricadeIcon from "virtual:icons/ph/barricade"
import { isEqual } from "lodash-es"
import { toRef, computed, watchEffect } from "vue"
import { useCurrentElement } from "@vueuse/core"
<script lang="ts" setup>
import { parseStringWithDetails, ShowPosition, START_STEP, state, Step } from "../state"
import CueBox from "./CueBox.vue"
import CaretDoubleRightIcon from "virtual:icons/ph/caret-double-right"
import CaretDoubleLeftIcon from "virtual:icons/ph/caret-double-left"
import BarricadeIcon from "virtual:icons/ph/barricade"
import { isEqual } from "lodash-es"
import { computed, toRef, watchEffect } from "vue"
import { useCurrentElement } from "@vueuse/core"
const props = defineProps<{
step: Step,
morePositions?: ShowPosition[]
centerCurrent: boolean
}>()
const props = defineProps<{
step: Step,
morePositions?: ShowPosition[]
centerCurrent: boolean
}>()
const position = toRef(state, "position")
const element = useCurrentElement()
const allPositions = computed(() => [props.step.position, ...(props.morePositions ?? [])])
const isActive = computed(() => allPositions.value.some(p => isEqual(p, position.value)))
const position = toRef(state, "position")
const element = useCurrentElement()
const allPositions = computed(() => [props.step.position, ...(props.morePositions ?? [])])
const isActive = computed(() => allPositions.value.some(p => isEqual(p, position.value)))
watchEffect(() => {
const p = props.step.position
if (isActive.value || (p.act + p.scene + p.step === 0 && isEqual(START_STEP.position, position.value))) {
element.value?.scrollIntoView({
behavior: "smooth",
block: props.centerCurrent ? "center" : "start",
inline: "nearest"
})
}
})
watchEffect(() => {
const p = props.step.position
if (isActive.value || (p.act + p.scene + p.step === 0 && isEqual(START_STEP.position, position.value))) {
element.value?.scrollIntoView({
behavior: "smooth",
block: props.centerCurrent ? "center" : "start",
inline: "nearest"
})
}
})
</script>

View file

@ -1,13 +1,13 @@
<template>
<div class="relative">
<div
class="bg-green-700 h-full transition-all"
:style="{ width: (progress * 100) + '%' }"
:class="barClass"
:class="barClass"
:style="{ width: (progress * 100) + '%' }"
class="bg-green-700 h-full transition-all"
></div>
<div
class="absolute left-0 top-0 w-full h-full px-4 text-4 text-white font-bold flex items-center transition flex justify-between"
:class="music === null ? 'opacity-0' : 'opacity-100'"
:class="music === null ? 'opacity-0' : 'opacity-100'"
class="absolute left-0 top-0 w-full h-full px-4 text-4 text-white font-bold flex items-center transition flex justify-between"
>
<div>
{{ lastMusic?.title ?? "" }}
@ -35,8 +35,8 @@
}
</style>
<script setup lang="ts">
import { state, current } from "../state"
<script lang="ts" setup>
import { current, state } from "../state"
import { computed, useCssModule } from "vue"
import { avoidNull, formatSeconds } from "../helpers"
import { syncedTime } from "../syncing"
@ -47,27 +47,27 @@
const styles = useCssModule()
const deltaInSeconds = computed(() => {
if (music.value === null) return 0
return (syncedTime.value - state.musicStartTime) / 1000
if (music.value === null) return 0
return (syncedTime.value - state.musicStartTime) / 1000
})
const progress = computed(() => {
if (music.value === null) return 0
return Math.min(1, deltaInSeconds.value / (music.value.duration / 1000))
if (music.value === null) return 0
return Math.min(1, deltaInSeconds.value / (music.value.duration / 1000))
})
const remainingSeconds = computed(() => {
if (lastMusic.value === null) return 0
return Math.max(0, (lastMusic.value.duration / 1000) - deltaInSeconds.value)
if (lastMusic.value === null) return 0
return Math.max(0, (lastMusic.value.duration / 1000) - deltaInSeconds.value)
})
const barClass = computed(() => {
if (lastMusic.value === null) return "bg-gray-700"
if (lastMusic.value === null) return "bg-gray-700"
if (remainingSeconds.value < 5) return styles.pulse + " bg-red-600"
if (remainingSeconds.value < 10) return "bg-red-600"
if (remainingSeconds.value < 20) return "bg-orange-600"
if (remainingSeconds.value < 5) return styles.pulse + " bg-red-600"
if (remainingSeconds.value < 10) return "bg-red-600"
if (remainingSeconds.value < 20) return "bg-orange-600"
return "bg-blue-700"
return "bg-blue-700"
})
</script>

View file

@ -1,10 +1,10 @@
<template>
<ChangeBlinkingBox class="items-center" :blink-seconds="20" :value="prop">
<ChangeBlinkingBox :blink-seconds="20" :value="prop" class="items-center">
<div class="text-s1 tracking-wide text-gray-500">
{{ positionName }}
</div>
<div class="flex-grow w-full">
<transition name="fade" mode="out-in">
<transition mode="out-in" name="fade">
<div :key="prop" class="flex flex-col items-center justify-center">
<template v-if="prop !== null">
<div class="font-bold text-3 text-center">
@ -32,21 +32,21 @@
}
</style>
<script setup lang="ts">
<script lang="ts" setup>
import { toRef, watch } from "vue"
import { autoResetRef } from "@vueuse/core"
import { parseStringWithDetails } from "../state"
import ChangeBlinkingBox from "./ChangeBlinkingBox.vue"
const props = defineProps<{
prop: string | null
positionName: string
prop: string | null
positionName: string
}>()
const prop = toRef(props, "prop")
const isBlinking = autoResetRef(false, 20 * 1000)
watch(prop, () => {
isBlinking.value = true
isBlinking.value = true
})
</script>

View file

@ -6,18 +6,18 @@
</div>
</div>
<div :class="$style.row" class="border-t border-b border-dark-300 h-30">
<PropBox position-name="Rechte Vorbühne" :prop="current.step.props.PROSCENIUM_RIGHT"/>
<PropBox position-name="Mitte der Vorbühne" :prop="current.step.props.PROSCENIUM_CENTER"/>
<PropBox position-name="Linke der Vorbühne" :prop="current.step.props.PROSCENIUM_LEFT"/>
<PropBox :prop="current.step.props.PROSCENIUM_RIGHT" position-name="Rechte Vorbühne"/>
<PropBox :prop="current.step.props.PROSCENIUM_CENTER" position-name="Mitte der Vorbühne"/>
<PropBox :prop="current.step.props.PROSCENIUM_LEFT" position-name="Linke der Vorbühne"/>
</div>
<div :class="$style.row" class="flex-grow h-0">
<PropBox position-name="Rechts" :prop="current.step.props.RIGHT"/>
<PropBox position-name="Mitte" :prop="current.step.props.CENTER"/>
<PropBox position-name="Links" :prop="current.step.props.LEFT"/>
<PropBox :prop="current.step.props.RIGHT" position-name="Rechts"/>
<PropBox :prop="current.step.props.CENTER" position-name="Mitte"/>
<PropBox :prop="current.step.props.LEFT" position-name="Links"/>
</div>
<div :class="$style.row" class="border-t border-dark-300 py-3 h-20">
<div/>
<PropBox position-name="Rückwand" :prop="current.step.props.BACKDROP"/>
<PropBox :prop="current.step.props.BACKDROP" position-name="Rückwand"/>
<div/>
</div>
</div>
@ -33,7 +33,7 @@
}
</style>
<script setup lang="ts">
<script lang="ts" setup>
import { current } from "../state"
import PropBox from "./PropBox.vue"
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="flex-grow overflow-y-auto bg-dark-800 flex flex-col pt-2">
<StepSelectionStep :step="START_STEP"/>
<div class="overflow-y-auto bg-dark-800 flex flex-col pt-2">
<StepSelectionStep :step="START_STEP"/>
<StepSelectionAct v-for="act in show.acts" :key="act.name" :act="act"/>
</div>
</template>
@ -9,8 +9,8 @@
</style>
<script setup lang="ts">
import { show, START_STEP } from "../state"
<script lang="ts" setup>
import { show, START_STEP } from "@/state"
import StepSelectionAct from "./StepSelectionAct.vue"
import StepSelectionStep from "./StepSelectionStep.vue"
</script>

View file

@ -1,25 +1,25 @@
<template>
<div class="pt-2">
<div class="font-bold text-7 flex gap-5 items-center">
<span class="flex-grow h-2px bg-white"></span>
<span>{{ act.name }}</span>
<span class="flex-grow h-2px bg-white"></span>
</div>
<div class="flex flex-col gap-2">
<StepSelectionScene v-for="scene in act.scenes" :key="scene" :scene="scene"/>
</div>
</div>
<div class="pt-2">
<div class="font-bold text-7 flex gap-5 items-center">
<span class="flex-grow h-2px bg-white"></span>
<span>{{ act.name }}</span>
<span class="flex-grow h-2px bg-white"></span>
</div>
<div class="flex flex-col gap-2">
<StepSelectionScene v-for="(scene, index) in act.scenes" :key="index" :scene="scene"/>
</div>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import StepSelectionScene from "./StepSelectionScene.vue"
import { Act } from "../state"
<script lang="ts" setup>
import StepSelectionScene from "./StepSelectionScene.vue"
import { Act } from "@/state"
const props = defineProps<{
act: Act
}>()
const props = defineProps<{
act: Act
}>()
</script>

View file

@ -1,26 +1,26 @@
<template>
<div
class="not-last:border-b border-dark-300 transition"
:class="scene === current.scene ? 'bg-green-900' : ''"
>
<div class="pb-1 px-4 text-4 font-bold">
{{ scene.name }}
</div>
<div class="flex flex-col pb-2">
<StepSelectionStep v-for="step in scene.steps" :key="step.position" :step="step"/>
</div>
</div>
<div
:class="scene === current.scene ? 'bg-green-900' : ''"
class="not-last:border-b border-dark-300 transition"
>
<div class="pb-1 px-4 text-4 font-bold">
{{ scene.name }}
</div>
<div class="flex flex-col pb-2">
<StepSelectionStep v-for="step in scene.steps" :key="step.position" :step="step"/>
</div>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import { current, Scene } from "../state"
import StepSelectionStep from "./StepSelectionStep.vue"
<script lang="ts" setup>
import { current, Scene } from "../state"
import StepSelectionStep from "./StepSelectionStep.vue"
const props = defineProps<{
scene: Scene
}>()
const props = defineProps<{
scene: Scene
}>()
</script>

View file

@ -1,43 +1,43 @@
<template>
<div
class="px-4 py-1 flex items-center justify-between space-x-2 transition "
:class="isActive ? 'bg-green-700' : ''"
>
<CueBox :step="step"/>
<button class="flex items-center text-4" @click="goToPosition(step.position)">
<KeyReturnIcon/>
</button>
</div>
<div
:class="isActive ? 'bg-green-700' : ''"
class="px-4 py-1 flex items-center justify-between gap-2 transition"
>
<CueBox :step="step"/>
<button class="flex items-center text-4" @click="goToPosition(step.position)">
<KeyReturnIcon/>
</button>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import { state, Step } from "../state"
import { goToPosition } from "../syncing"
import KeyReturnIcon from "virtual:icons/ph/key-return"
import CueBox from "./CueBox.vue"
import { useCurrentElement } from "@vueuse/core"
import { computed, toRef, watchEffect } from "vue"
import { isEqual } from "lodash-es"
<script lang="ts" setup>
import { state, Step } from "@/state"
import { goToPosition } from "@/syncing"
import KeyReturnIcon from "virtual:icons/ph/key-return"
import CueBox from "./CueBox.vue"
import { useCurrentElement } from "@vueuse/core"
import { computed, toRef, watchEffect } from "vue"
import { isEqual } from "lodash-es"
const props = defineProps<{
step: Step
}>()
const props = defineProps<{
step: Step
}>()
const position = toRef(state, "position")
const element = useCurrentElement()
const isActive = computed(() => isEqual(props.step.position, position.value))
const position = toRef(state, "position")
const element = useCurrentElement<HTMLElement>()
const isActive = computed(() => isEqual(props.step.position, position.value))
watchEffect(() => {
if (isActive.value) {
element.value?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest"
})
}
})
watchEffect(() => {
if (isActive.value) {
element.value?.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest"
})
}
})
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="absolute right-3 top-2 text-5 pointer-events-none flex items-center gap-1">
<ClockIcon/>
<ClockIcon/>
<span class="font-bold tabular-nums tracking-tighter">{{ format.format(syncedTime) }}</span>
</div>
</template>
@ -9,7 +9,7 @@
</style>
<script setup lang="ts">
<script lang="ts" setup>
import { syncedTime } from "../syncing"
import ClockIcon from "virtual:icons/ph/clock-bold"

48
ui/src/global.scss Normal file
View file

@ -0,0 +1,48 @@
@import "modern-normalize/modern-normalize.css";
:root {
color-scheme: dark;
}
html, body, #app {
width: 100dvw;
height: 100dvh;
font-family: "Manrope Variable", sans-serif;
user-select: none;
background: black;
color: white;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 10%);
&:hover {
background: rgb(255 255 255 / 20%);
}
}
::selection {
background: rgba(255 255 255 / 20%)
}
a {
text-decoration: none;
color: currentColor;
}
button {
background: transparent;
border: none;
color: inherit;
font: inherit;
padding: 0;
&:focus-visible {
outline: solid 2px theme("colors.blue.400");
outline-offset: 1px;
}
}

View file

@ -1,28 +1,28 @@
import { Ref, ComputedRef, computed, UnwrapRef, ref, watchEffect } from "vue"
import { computed, ComputedRef, Ref, ref, UnwrapRef, watchEffect } from "vue"
export const computedIfPresent = <T, V>(object: Ref<T>, access: (value: Exclude<T, undefined | null>) => V): ComputedRef<V | undefined | null> => computed(() => {
if (object.value === null) return null
else if (object.value === undefined) return undefined
if (object.value === null) return null
else if (object.value === undefined) return undefined
return access(object.value as Exclude<T, undefined | null>)
return access(object.value as Exclude<T, undefined | null>)
})
export const computedOrNull = <T, V>(object: Ref<T | null>, access: (value: T) => V): ComputedRef<V | null> => computed(() => {
if (object.value === null) return null
return access(object.value)
if (object.value === null) return null
return access(object.value)
})
export function avoidNull<T>(originRef: Ref<UnwrapRef<T> | null>): ComputedRef<UnwrapRef<T> | null> {
const nullAvoidingRef = ref<T | null>(null)
const nullAvoidingRef = ref<T | null>(null)
watchEffect(() => {
if (originRef.value !== null) nullAvoidingRef.value = originRef.value
})
watchEffect(() => {
if (originRef.value !== null) nullAvoidingRef.value = originRef.value
})
return computed(() => nullAvoidingRef.value)
return computed(() => nullAvoidingRef.value)
}
export function formatSeconds(seconds: number) {
const duration = new Date(seconds * 1000)
return `${duration.getMinutes().toFixed()}:${duration.getSeconds().toFixed().padStart(2, '0')}`
const duration = new Date(seconds * 1000)
return `${duration.getMinutes().toFixed()}:${duration.getSeconds().toFixed().padStart(2, "0")}`
}

View file

@ -1,22 +1,18 @@
import "virtual:windi.css"
import "virtual:uno.css"
import { createApp } from "vue"
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"
import { createRouter, createWebHistory } from "vue-router"
import App from "./App.vue"
import originalRoutes from "~pages"
const routes = originalRoutes.map(route => {
if (typeof route.component !== "function") return route
return {
...route,
props: false
}
}) as RouteRecordRaw[]
import { handleHotUpdate, routes } from "vue-router/auto-routes"
const router = createRouter({
routes,
history: createWebHistory()
routes,
history: createWebHistory()
})
const app = createApp(App)
app.use(router)
app.mount("#app")
if (import.meta.hot) {
handleHotUpdate(router)
}

View file

@ -1,42 +0,0 @@
<template>
<div class="flex flex-col overflow-hidden">
<div class="flex flex-col space-y-4 p-4 pt-8 flex-grow h-full overflow-hidden">
<div class="flex justify-end">
<button class="px-5 py-3 bg-green-600 font-bold text-5" @click="goNext()">
Next
</button>
</div>
<StepSelection class="h-110"/>
<FogControl/>
<CurtainLightControl/>
</div>
</div>
</template>
<style>
html {
user-select: none;
}
</style>
<script setup lang="ts">
import { current, getNextValidPosition, getPreviousValidPosition, state } from "../state"
import { goToPosition } from "../syncing"
import { onKeyStroke } from "@vueuse/core"
import StepSelection from "../components/StepSelection.vue"
import FogControl from "../components/FogControl.vue"
import CurtainLightControl from "../components/CurtainLightControl.vue"
function goNext() {
const position = getNextValidPosition(state.position)
if (position !== null) goToPosition(position)
}
function goPrevious() {
const position = getPreviousValidPosition(state.position)
if (position !== null) goToPosition(position)
}
onKeyStroke("ArrowLeft", goPrevious)
onKeyStroke("ArrowRight", goNext)
</script>

View file

@ -1,40 +0,0 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}`}}{{ current.scene.name }}
</h1>
<div class="h-full flex space-x-4 p-10 pt-8 flex-grow overflow-hidden">
<StepSelection class="w-1/2"/>
<div class="w-1/2 flex flex-col space-y-4">
<MessageEdit class="h-1/2"/>
<div class="text-4">Vorhang: {{ current.step.curtainState === "open" ? "auf" : "geschlossen"}}</div>
<ActorsOnStageBox class="flex-grow"/>
<FogControl/>
<CurtainLightControl/>
</div>
</div>
<MusicProgressBar class="h-10"/>
</div>
</template>
<script setup lang="ts">
import MusicProgressBar from "../components/MusicProgressBar.vue"
import { onKeyStroke } from "@vueuse/core"
import StepSelection from "../components/StepSelection.vue"
import MessageEdit from "../components/MessageEdit.vue"
import ActorsOnStageBox from "../components/ActorsOnStageBox.vue"
import { goToPosition } from "../syncing"
import { current, getNextValidPosition, getPreviousValidPosition, state } from "../state"
import FogControl from "../components/FogControl.vue"
import CurtainLightControl from "../components/CurtainLightControl.vue"
onKeyStroke("ArrowRight", () => {
const position = getNextValidPosition(state.position)
if (position !== null) goToPosition(position)
})
onKeyStroke("ArrowLeft", () => {
const position = getPreviousValidPosition(state.position)
if (position !== null) goToPosition(position)
})
</script>

View file

@ -0,0 +1,25 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}` }}{{ current.scene.name }}
</h1>
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
<MotionsList center-current class="w-3/7" scrollable/>
<div class="w-4/7 flex flex-col space-y-4">
<ActorsOnStageBox class="h-full text-7"/>
<MessageEdit class="h-40"/>
<CurtainLightControl/>
</div>
</div>
<MusicProgressBar class="h-10"/>
</div>
</template>
<script lang="ts" setup>
import MusicProgressBar from "../../components/MusicProgressBar.vue"
import MotionsList from "../../components/MotionsList.vue"
import { current } from "../../state"
import ActorsOnStageBox from "../../components/ActorsOnStageBox.vue"
import MessageEdit from "../../components/MessageEdit.vue"
import CurtainLightControl from "../../components/CurtainLightControl.vue"
</script>

View file

@ -0,0 +1,63 @@
<template>
<div class="grid md:grid-cols-2 gap-6 h-full" :class="$style.root">
<StepSelection/>
<div class="flex flex-col gap-5 overflow-y-auto">
<MessageEdit class="h-25 md:h-40"/>
<div class="text-5">
<div class="pb-2 font-bold text-3.5 tracking-wider uppercase">
Vorhang
</div>
<div class="text-4.5">
{{ current.step.curtainState === "open" ? "öffnen" : "schließen" }}
</div>
</div>
<div class="flex-grow overflow-hidden">
<div class="pb-2 font-bold text-3.5 tracking-wider uppercase">
Auf der Bühne
</div>
<ActorsOnStageBox class="flex-grow h-full"/>
</div>
<div>
<div class="pb-2 font-bold text-3.5 tracking-wider uppercase">
Nebel
</div>
<FogControl/>
</div>
<div>
<div class="pb-2 font-bold text-3.5 tracking-wider uppercase">
Umbaulicht
</div>
<CurtainLightControl/>
</div>
</div>
</div>
</template>
<style module lang="scss">
.root {
@screen lt-md {
grid-template-rows: 200px;
}
}
</style>
<script lang="ts" setup>
import { onKeyStroke } from "@vueuse/core"
import StepSelection from "../../components/StepSelection.vue"
import MessageEdit from "../../components/MessageEdit.vue"
import ActorsOnStageBox from "../../components/ActorsOnStageBox.vue"
import { goToPosition } from "@/syncing"
import { current, getNextValidPosition, getPreviousValidPosition, state } from "@/state"
import FogControl from "../../components/FogControl.vue"
import CurtainLightControl from "../../components/CurtainLightControl.vue"
onKeyStroke("ArrowRight", () => {
const position = getNextValidPosition(state.position)
if (position !== null) goToPosition(position)
})
onKeyStroke("ArrowLeft", () => {
const position = getPreviousValidPosition(state.position)
if (position !== null) goToPosition(position)
})
</script>

View file

@ -0,0 +1,71 @@
<template>
<div class="flex flex-col gap-50">
<div class="flex flex-col gap-2">
<div class="text-gray text-2xl">
Aktuelles Ziel
</div>
<ChangeBlinkingBox :blink-seconds="currentTarget === null ? 0 : 10" :value="currentTarget" class="text-7 md:text-10">
{{ currentTarget ?? "Niemand" }}
</ChangeBlinkingBox>
</div>
<div v-if="nextStepWithChange !== null" class="flex flex-col gap-2">
<div class="text-gray text-6">
Nächstes Ziel
<span class="whitespace-nowrap">
[{{
nextStepWithChange.delta === 0
? "in dieser Szene"
: nextStepWithChange.delta === 1
? "in der nächsten Szene"
: `in ${nextStepWithChange.delta} Szenen`
}}]
</span>
</div>
<div class="text-7 md:text-10">{{ nextStepWithChange.target }}</div>
</div>
</div>
</template>
<style module>
</style>
<script lang="ts" setup>
import { useRoute } from "vue-router"
import { computed } from "vue"
import { current, getNextValidPosition, getSceneIndex, getStep, ShowPosition } from "@/state"
import ChangeBlinkingBox from "../../components/ChangeBlinkingBox.vue"
const route = useRoute()
const isLeft = computed(() => route.query.side === "left")
const targetProperty = computed<"leftSpotTarget" | "rightSpotTarget">(() => isLeft.value ? "leftSpotTarget" : "rightSpotTarget")
const currentTarget = computed(() => current.step[targetProperty.value])
interface StepWithChange {
delta: number
position: ShowPosition
target: string
}
const nextStepWithChange = computed<StepWithChange | null>(() => {
let position: ShowPosition | null = getNextValidPosition(current.step.position)
let lastTarget: string | null = currentTarget.value
while (position !== null) {
const step = getStep(position)
if (step[targetProperty.value] !== null && step[targetProperty.value] !== lastTarget) {
return {
position: step.position,
delta: getSceneIndex(step.position) - current.sceneIndex,
target: step[targetProperty.value]!
}
}
lastTarget = step[targetProperty.value]
position = getNextValidPosition(position)
}
return null
})
</script>

13
ui/src/pages/index.vue Normal file
View file

@ -0,0 +1,13 @@
<template>
<div class="flex flex-col gap-4 text-xl">
<router-link to="/mixer">audio operator</router-link>
</div>
</template>
<style module>
</style>
<script lang="ts" setup>
</script>

View file

@ -1,8 +1,8 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}`}}{{ current.scene.name }}
</h1>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}` }}{{ current.scene.name }}
</h1>
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
<MotionsList center-current class="w-3/7"/>
<div class="w-4/7 flex flex-col space-y-4">
@ -14,7 +14,7 @@
</div>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import MusicProgressBar from "../../components/MusicProgressBar.vue"
import { current } from "../../state"
import MotionsList from "../../components/MotionsList.vue"

View file

@ -1,25 +0,0 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}`}}{{ current.scene.name }}
</h1>
<div class="h-full flex space-x-4 p-4 pt-8 flex-grow overflow-hidden">
<MotionsList scrollable center-current class="w-3/7"/>
<div class="w-4/7 flex flex-col space-y-4">
<ActorsOnStageBox class="h-full text-7"/>
<MessageEdit class="h-40"/>
<CurtainLightControl/>
</div>
</div>
<MusicProgressBar class="h-10"/>
</div>
</template>
<script setup lang="ts">
import MusicProgressBar from "../components/MusicProgressBar.vue"
import MotionsList from "../components/MotionsList.vue"
import { current } from "../state"
import ActorsOnStageBox from "../components/ActorsOnStageBox.vue"
import MessageEdit from "../components/MessageEdit.vue"
import CurtainLightControl from "../components/CurtainLightControl.vue"
</script>

View file

@ -1,77 +0,0 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}`}}{{ current.scene.name }}
</h1>
<div class="h-full flex flex-col space-y-10 p-7 text-6">
<div class="flex flex-col gap-1">
<div>
Aktuelles Ziel:
</div>
<ChangeBlinkingBox class="h-full min-w-50" :value="currentTarget" :blink-seconds="10">
{{ currentTarget ?? "Niemand" }}
</ChangeBlinkingBox>
</div>
<div v-if="nextStepWithChange !== null" class="h-full flex flex-col space-y-2 text-5">
<div class="flex gap-5 items-center">
<div>Nächstes Ziel:</div>
<div>{{ nextStepWithChange.target }}</div>
</div>
<div>
{{ nextStepWithChange.delta === 0
? "in dieser Szene (rechtzeitig positionieren!)"
: nextStepWithChange.delta === 1
? "in der nächsten Szene"
: `in ${nextStepWithChange.delta} Szenen`
}}
</div>
</div>
</div>
<MusicProgressBar class="h-10"/>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import { useRoute } from "vue-router"
import { computed } from "vue"
import { current, getNextValidPosition, getSceneIndex, getStep, ShowPosition, START_STEP } from "../state"
import ChangeBlinkingBox from "../components/ChangeBlinkingBox.vue"
import MusicProgressBar from "../components/MusicProgressBar.vue"
const route = useRoute()
const isLeft = computed(() => route.query.side === "left")
const targetProperty = computed<"leftSpotTarget" | "rightSpotTarget">(() => isLeft.value ? "leftSpotTarget" : "rightSpotTarget")
const currentTarget = computed(() => current.step[targetProperty.value])
interface StepWithChange {
delta: number
position: ShowPosition
target: string
}
const nextStepWithChange = computed<StepWithChange | null>(() => {
let position: ShowPosition | null = getNextValidPosition(current.step.position)
let lastTarget: string | null = currentTarget.value
while(position !== null) {
const step = getStep(position)
if (step[targetProperty.value] !== null && step[targetProperty.value] !== lastTarget) {
return {
position: step.position,
delta: getSceneIndex(step.position) - current.sceneIndex,
target: step[targetProperty.value]!
}
}
lastTarget = step[targetProperty.value]
position = getNextValidPosition(position)
}
return null
})
</script>

View file

@ -1,77 +0,0 @@
<template>
<div class="flex flex-col h-full">
<h1 class="font-800 text-9 p-4 pb-0">
{{ current.act === null ? "" : `${current.act.name}`}}{{ current.scene.name }}
</h1>
<div class="h-full flex flex-col space-y-10 p-10 text-10">
<div class="flex gap-5 items-center h-18">
<div>
Aktuelles Ziel:
</div>
<ChangeBlinkingBox class="px-8 h-full min-w-50 items-center" :value="currentTarget" :blink-seconds="10">
{{ currentTarget ?? "Niemand" }}
</ChangeBlinkingBox>
</div>
<div v-if="nextStepWithChange !== null" class="h-full flex flex-col space-y-2 text-7">
<div class="flex gap-5 items-center">
<div>Nächstes Ziel:</div>
<div>{{ nextStepWithChange.target }}</div>
</div>
<div>
{{ nextStepWithChange.delta === 0
? "in dieser Szene (rechtzeitig positionieren!)"
: nextStepWithChange.delta === 1
? "in der nächsten Szene"
: `in ${nextStepWithChange.delta} Szenen`
}}
</div>
</div>
</div>
<MusicProgressBar class="h-10"/>
</div>
</template>
<style module>
</style>
<script setup lang="ts">
import { useRoute } from "vue-router"
import { computed } from "vue"
import { current, getNextValidPosition, getSceneIndex, getStep, ShowPosition, START_STEP } from "../state"
import ChangeBlinkingBox from "../components/ChangeBlinkingBox.vue"
import MusicProgressBar from "../components/MusicProgressBar.vue"
const route = useRoute()
const isLeft = computed(() => route.query.side === "left")
const targetProperty = computed<"leftSpotTarget" | "rightSpotTarget">(() => isLeft.value ? "leftSpotTarget" : "rightSpotTarget")
const currentTarget = computed(() => current.step[targetProperty.value])
interface StepWithChange {
delta: number
position: ShowPosition
target: string
}
const nextStepWithChange = computed<StepWithChange | null>(() => {
let position: ShowPosition | null = getNextValidPosition(current.step.position)
let lastTarget: string | null = currentTarget.value
while(position !== null) {
const step = getStep(position)
if (step[targetProperty.value] !== null && step[targetProperty.value] !== lastTarget) {
return {
position: step.position,
delta: getSceneIndex(step.position) - current.sceneIndex,
target: step[targetProperty.value]!
}
}
lastTarget = step[targetProperty.value]
position = getNextValidPosition(position)
}
return null
})
</script>

View file

@ -2,246 +2,246 @@ import { reactive, shallowRef } from "vue"
import { reactiveComputed } from "@vueuse/core"
export interface Act {
name: string
scenes: Scene[]
name: string
scenes: Scene[]
}
export interface Scene {
name: string
steps: Step[]
name: string
steps: Step[]
}
export interface Step {
position: ShowPosition
cue: StepCue
actorEntrances: string[]
actorExits: string[]
actorsOnStage: string[]
props: PropMap
hasChangedProps: boolean
leftSpotTarget: string | null
rightSpotTarget: string | null
curtainState: "open" | "closed"
position: ShowPosition
cue: StepCue
actorEntrances: string[]
actorExits: string[]
actorsOnStage: string[]
props: PropMap
hasChangedProps: boolean
leftSpotTarget: string | null
rightSpotTarget: string | null
curtainState: "open" | "closed"
}
export type StepCue = {
type: "TEXT",
speaker: string
text: string
clarification?: string
type: "TEXT",
speaker: string
text: string
clarification?: string
} | {
type: "MUSIC_START",
title: string
duration: number
type: "MUSIC_START",
title: string
duration: number
} | {
type: "MUSIC_END"
type: "MUSIC_END"
} | {
type: "CURTAIN",
state: "open" | "closed"
whileMoving: boolean
type: "CURTAIN",
state: "open" | "closed"
whileMoving: boolean
} | {
type: "LIGHTS"
state: "on" | "off"
whileFading: boolean
type: "LIGHTS"
state: "on" | "off"
whileFading: boolean
} | {
type: "CUSTOM"
text: string
type: "CUSTOM"
text: string
}
export type PropMap = Record<PropPosition, string | null>
export type PropPosition =
| "PROSCENIUM_LEFT"
| "PROSCENIUM_CENTER"
| "PROSCENIUM_RIGHT"
| "CENTER"
| "LEFT"
| "RIGHT"
| "BACKDROP"
| "PROSCENIUM_LEFT"
| "PROSCENIUM_CENTER"
| "PROSCENIUM_RIGHT"
| "CENTER"
| "LEFT"
| "RIGHT"
| "BACKDROP"
export interface ShowState {
position: ShowPosition
message: string
activeMusic: ShowMusic | null
musicStartTime: number
isLightBehindCurtainOn: boolean
position: ShowPosition
message: string
activeMusic: ShowMusic | null
musicStartTime: number
isLightBehindCurtainOn: boolean
}
export interface ShowMusic {
title: string
duration: number
title: string
duration: number
}
export interface ShowPosition {
act: number
scene: number
step: number
act: number
scene: number
step: number
}
export interface Show {
acts: Act[]
acts: Act[]
}
export const START_STEP: Step = {
position: { act: -1, scene: 0, step: 0 },
actorsOnStage: [],
cue: {
type: "CUSTOM",
text: "Start"
},
props: {
BACKDROP: null,
LEFT: null,
CENTER: null,
RIGHT: null,
PROSCENIUM_LEFT: null,
PROSCENIUM_CENTER: null,
PROSCENIUM_RIGHT: null
},
hasChangedProps: false,
actorEntrances: [],
actorExits: [],
leftSpotTarget: null,
rightSpotTarget: null,
curtainState: "closed"
position: { act: -1, scene: 0, step: 0 },
actorsOnStage: [],
cue: {
type: "CUSTOM",
text: "Start"
},
props: {
BACKDROP: null,
LEFT: null,
CENTER: null,
RIGHT: null,
PROSCENIUM_LEFT: null,
PROSCENIUM_CENTER: null,
PROSCENIUM_RIGHT: null
},
hasChangedProps: false,
actorEntrances: [],
actorExits: [],
leftSpotTarget: null,
rightSpotTarget: null,
curtainState: "closed"
}
const START_SCENE: Scene = {
name: "Start",
steps: [START_STEP]
name: "Start",
steps: [START_STEP]
}
export const show = shallowRef<Show>({
acts: []
acts: []
})
export const state = reactive<ShowState>({
position: START_STEP.position,
message: "",
activeMusic: null,
musicStartTime: 0,
isLightBehindCurtainOn: false
position: START_STEP.position,
message: "",
activeMusic: null,
musicStartTime: 0,
isLightBehindCurtainOn: false
})
export function getStep(position: ShowPosition) {
if (position.act === -1) return START_STEP
return getScene(position).steps[position.step]
if (position.act === -1) return START_STEP
return getScene(position).steps[position.step]
}
export function getScene(position: ShowPosition) {
if (position.act === -1) return START_SCENE
return getAct(position)!.scenes[position.scene]
if (position.act === -1) return START_SCENE
return getAct(position)!.scenes[position.scene]
}
export function getAct(position: ShowPosition) {
if (position.act === -1) return null
return show.value.acts[position.act]
if (position.act === -1) return null
return show.value.acts[position.act]
}
export function getSceneIndex(position: ShowPosition) {
let index = position.scene
let index = position.scene
for (let actIndex = 0; actIndex < position.act; actIndex++) {
index += show.value.acts[actIndex]!.scenes.length
}
for (let actIndex = 0; actIndex < position.act; actIndex++) {
index += show.value.acts[actIndex]!.scenes.length
}
return index
return index
}
export function getActiveMusicAt(position: ShowPosition): ShowMusic | null {
let activeMusic: ShowMusic | null = null
let activeMusic: ShowMusic | null = null
for (let actIndex = 0; actIndex < show.value.acts.length; actIndex++) {
const scenes = show.value.acts[actIndex].scenes
for (let actIndex = 0; actIndex < show.value.acts.length; actIndex++) {
const scenes = show.value.acts[actIndex].scenes
for (let sceneIndex = 0; sceneIndex < scenes.length; sceneIndex++) {
const scene = scenes[sceneIndex]
for (let sceneIndex = 0; sceneIndex < scenes.length; sceneIndex++) {
const scene = scenes[sceneIndex]
for (let stepIndex = 0; stepIndex < scene.steps.length; stepIndex++) {
const step = scene.steps[stepIndex]
for (let stepIndex = 0; stepIndex < scene.steps.length; stepIndex++) {
const step = scene.steps[stepIndex]
// SONG
if (step.cue.type === "MUSIC_START") {
activeMusic = {
title: step.cue.title,
duration: step.cue.duration
}
} else if (step.cue.type === "MUSIC_END") {
activeMusic = null
// SONG
if (step.cue.type === "MUSIC_START") {
activeMusic = {
title: step.cue.title,
duration: step.cue.duration
}
} else if (step.cue.type === "MUSIC_END") {
activeMusic = null
}
if (sceneIndex == position.scene && stepIndex == position.step) return activeMusic
}
}
if (sceneIndex == position.scene && stepIndex == position.step) return activeMusic
}
}
}
return null
return null
}
export const current = reactiveComputed<{
act: Act | null
scene: Scene
sceneIndex: number
step: Step
activeMusic: ShowMusic | null
allScenes: Scene[]
act: Act | null
scene: Scene
sceneIndex: number
step: Step
activeMusic: ShowMusic | null
allScenes: Scene[]
}>(() => ({
act: getAct(state.position),
scene: getScene(state.position),
sceneIndex: getSceneIndex(state.position),
step: getStep(state.position),
activeMusic: state.activeMusic,
allScenes: show.value.acts.flatMap(act => act.scenes)
act: getAct(state.position),
scene: getScene(state.position),
sceneIndex: getSceneIndex(state.position),
step: getStep(state.position),
activeMusic: state.activeMusic,
allScenes: show.value.acts.flatMap(act => act.scenes)
}))
export function parseStringWithDetails(string: string) {
const parts = string.split(" / ")
if (parts.length === 1) return { main: parts[0], details: "" }
return { main: parts[0], details: parts.slice(1).join(" / ") }
const parts = string.split(" / ")
if (parts.length === 1) return { main: parts[0], details: "" }
return { main: parts[0], details: parts.slice(1).join(" / ") }
}
export function getNextValidPosition(start: ShowPosition): ShowPosition | null {
if (start.act === START_STEP.position.act) return { act: 0, scene: 0, step: 0 }
const acts = show.value.acts
let { act, scene, step } = start
if (start.act === START_STEP.position.act) return { act: 0, scene: 0, step: 0 }
const acts = show.value.acts
let { act, scene, step } = start
step++
step++
if (step >= acts[start.act].scenes[start.scene].steps.length) {
step = 0
scene++
if (step >= acts[start.act].scenes[start.scene].steps.length) {
step = 0
scene++
if (scene >= acts[start.act].scenes.length) {
scene = 0
act++
if (scene >= acts[start.act].scenes.length) {
scene = 0
act++
if (act >= acts.length) {
return null
}
if (act >= acts.length) {
return null
}
}
}
}
return { act, scene, step }
return { act, scene, step }
}
export function getPreviousValidPosition(start: ShowPosition): ShowPosition | null {
let { act, scene, step } = start
step--
let { act, scene, step } = start
step--
if (step < 0) {
scene--
if (step < 0) {
scene--
if (scene < 0) {
act--
if (scene < 0) {
act--
if (act < 0) {
return START_STEP.position
}
if (act < 0) {
return START_STEP.position
}
scene = show.value.acts[act].scenes.length - 1
scene = show.value.acts[act].scenes.length - 1
}
step = show.value.acts[act].scenes[scene].steps.length - 1
}
step = show.value.acts[act].scenes[scene].steps.length - 1
}
return { act, scene, step }
return { act, scene, step }
}

View file

@ -9,50 +9,50 @@ export const getSyncedTime = () => Date.now() - timeDifference
export const syncedTime = ref(Date.now())
const setSyncedTimeRef = () => {
syncedTime.value = getSyncedTime()
requestAnimationFrame(setSyncedTimeRef)
syncedTime.value = getSyncedTime()
requestAnimationFrame(setSyncedTimeRef)
}
requestAnimationFrame(setSyncedTimeRef)
export const connect = () => new Promise<void>(resolve => {
if (socket !== null) return
if (socket !== null) return
const url = new URL(window.location.toString())
url.protocol = "ws"
url.pathname = "/api/ws"
socket = new ReconnectingWebSocket(url.toString(), [], {
reconnectionDelayGrowFactor: 1,
minReconnectionDelay: 1000
})
const url = new URL(window.location.toString())
url.protocol = "ws"
url.pathname = "/api/ws"
socket = new ReconnectingWebSocket(url.toString(), [], {
reconnectionDelayGrowFactor: 1,
minReconnectionDelay: 1000
})
let isFirstMessage = true
let isFirstMessage = true
socket.addEventListener("open", () => {
isFirstMessage = true
})
socket.addEventListener("open", () => {
isFirstMessage = true
})
socket.addEventListener("message", event => {
const data = JSON.parse(event.data as string)
socket.addEventListener("message", event => {
const data = JSON.parse(event.data as string)
if (isFirstMessage) {
isFirstMessage = false
show.value = data
console.log("Show:", data)
} else {
Object.assign(state, data.state)
timeDifference = Date.now() - data.timestamp
console.log("New state:", data.state)
console.log("New time difference:", timeDifference)
resolve()
}
})
if (isFirstMessage) {
isFirstMessage = false
show.value = data
console.log("Show:", data)
} else {
Object.assign(state, data.state)
timeDifference = Date.now() - data.timestamp
console.log("New state:", data.state)
console.log("New time difference:", timeDifference)
resolve()
}
})
})
export async function goToPosition(position: ShowPosition) {
await fetch("/api/go", { method: "POST", body: JSON.stringify(position), headers: { "Content-Type": "application/json" } })
await fetch("/api/go", { method: "POST", body: JSON.stringify(position), headers: { "Content-Type": "application/json" } })
}
export async function setMessage(message: string) {
await fetch("/api/message", { method: "POST", body: message })
await fetch("/api/message", { method: "POST", body: message })
}

View file

@ -1,3 +0,0 @@
/// <reference types="vite-plugin-pages/client"/>
/// <reference types="unplugin-icons/types/vue"/>
/// <reference types="vite/client"/>

View file

@ -1,20 +1,37 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ES2021",
"lib": ["DOM", "ESNext"],
"strict": true,
"declaration": false,
"esModuleInterop": true,
"skipLibCheck": false,
"moduleResolution": "node",
"forceConsistentCasingInFileNames": true,
"lib": [
"esnext",
"dom",
],
"module": "esnext",
"moduleResolution": "Bundler",
"allowJs": true,
"resolveJsonModule": true,
"strictNullChecks": true,
"downlevelIteration": true,
"forceConsistentCasingInFileNames": true
"isolatedModules": true,
"rootDir": "src",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"stripInternal": true,
"noUncheckedIndexedAccess": false,
"target": "esnext",
"types": [
"vite/client",
"unplugin-vue-router/client",
"unplugin-icons/types/vue"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"node_modules/**/*",
"src/**/*"
"src/**/*.ts",
"src/**/*.vue",
]
}

44
ui/unocss.config.ts Executable file
View file

@ -0,0 +1,44 @@
import { defineConfig, transformerDirectives } from "unocss"
import { presetWind, colors } from "@unocss/preset-wind3"
const generateValues = (max: number, fn: (step: number) => any) => {
const object: Record<number, any> = {}
for (let i = 1; i <= max; i++) {
object[i] = fn(i)
}
return object
}
export default defineConfig({
presets: [
presetWind({
arbitraryVariants: true,
preflight: true
})
],
theme: {
fontFamily: {
sans: `"Manrope Variable", sans-serif`,
system: "sans-serif"
},
colors: {
black: colors.black,
white: colors.white,
gray: colors.stone,
red: colors.red,
yellow: colors.amber,
orange: colors.orange,
green: colors.green,
blue: colors.blue,
violet: colors.fuchsia,
light: colors.light,
dark: colors.dark,
transparent: colors.transparent
}
},
transformers: [
transformerDirectives()
]
})

View file

@ -1,28 +1,39 @@
import { defineConfig, splitVendorChunkPlugin } from "vite"
import { defineConfig } from "vite"
import vuePlugin from "@vitejs/plugin-vue"
import windicssPlugin from "vite-plugin-windicss"
import pagesPlugin from "vite-plugin-pages"
import iconsPlugin from "unplugin-icons/vite"
import vueRouterPlugin from "unplugin-vue-router/vite"
import unocssPlugin from "unocss/vite"
import { resolve } from "node:path"
export default defineConfig({
plugins: [
splitVendorChunkPlugin(),
vuePlugin(),
pagesPlugin({
importMode: "sync"
vueRouterPlugin({
dts: "./src/generated-types/router.d.ts",
routesFolder: "./src/pages",
}),
windicssPlugin(),
iconsPlugin()
unocssPlugin(),
iconsPlugin(),
vuePlugin()
],
resolve: {
alias: {
"@": resolve(__dirname, "./src")
}
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler"
}
}
},
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
ws: true
}
}
},
build: {
reportCompressedSize: false
},
allowedHosts: true
}
})

View file

@ -1,44 +0,0 @@
import { defineConfig } from "windicss/helpers"
import colors from "windicss/colors"
import lineClampPlugin from "windicss/plugin/line-clamp"
const generateValues = (max: number, fn: (step: number) => any) => {
const object: Record<number, any> = {}
for (let i = 1; i <= max; i++) {
object[i] = fn(i)
}
return object
}
export default defineConfig({
theme: {
colors: {
black: colors.black,
white: colors.white,
gray: colors.stone,
red: colors.red,
yellow: colors.amber,
orange: colors.orange,
green: colors.green,
blue: colors.blue,
violet: colors.fuchsia,
light: colors.light,
dark: colors.dark,
transparent: colors.transparent
},
fontSize: {
...generateValues(30, step => `${step * 0.25}rem`),
4: "1.2rem",
3: "1.1rem",
2: "1rem",
s1: "0.9rem",
s2: "0.8rem",
s3: "0.7rem"
}
},
plugins: [
lineClampPlugin
]
})