705 lines
24 KiB
Vue
705 lines
24 KiB
Vue
<template>
|
||
<div class="m-shell" data-mobile="1">
|
||
<header class="m-header">
|
||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||
<label class="m-crumb">
|
||
<select v-model="ctx.form.folderId" aria-label="Folder">
|
||
<option v-for="f in ctx.library.folders" :key="f.id" :value="f.id">{{ f.name }}</option>
|
||
</select>
|
||
</label>
|
||
<span class="m-grow" />
|
||
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||
<details class="m-more">
|
||
<summary aria-label="More">⋯</summary>
|
||
<div class="m-more-menu">
|
||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||
</div>
|
||
</details>
|
||
</header>
|
||
|
||
<div class="m-chips" role="tablist" aria-label="Tasks">
|
||
<button
|
||
v-for="task in ctx.tasks"
|
||
:key="task"
|
||
type="button"
|
||
class="m-chip capitalize"
|
||
:class="{ on: ctx.form.mode===task }"
|
||
:aria-pressed="ctx.form.mode===task"
|
||
@click="ctx.chooseTask(task)"
|
||
>{{ task }}</button>
|
||
</div>
|
||
|
||
<div v-if="ctx.form.mode!=='music'" class="m-chips" role="group" aria-label="Model">
|
||
<button
|
||
v-for="opt in ctx.engineOptions"
|
||
:key="opt.value"
|
||
type="button"
|
||
class="m-chip"
|
||
:class="{ on: ctx.form.engine===opt.value }"
|
||
:disabled="opt.disabled"
|
||
:title="opt.hint || undefined"
|
||
:aria-pressed="ctx.form.engine===opt.value"
|
||
@click="!opt.disabled && (ctx.form.engine=opt.value)"
|
||
>{{ opt.label }}</button>
|
||
</div>
|
||
<div v-else class="m-chips"><span class="m-chip on">YuE2</span></div>
|
||
|
||
<div
|
||
class="m-stage"
|
||
@touchstart.passive="onStageTouchStart"
|
||
@touchend.passive="onStageTouchEnd"
|
||
>
|
||
<div class="m-stage-chrome" aria-label="Stage controls">
|
||
<button
|
||
type="button"
|
||
class="m-icon-btn m-library"
|
||
:aria-expanded="ctx.libraryOpen ? 'true' : 'false'"
|
||
aria-label="Library"
|
||
@click="ctx.openLibrary()"
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<rect x="3" y="4" width="7" height="7" rx="1.5" />
|
||
<rect x="14" y="4" width="7" height="7" rx="1.5" />
|
||
<rect x="3" y="13" width="7" height="7" rx="1.5" />
|
||
<rect x="14" y="13" width="7" height="7" rx="1.5" />
|
||
</svg>
|
||
<span v-if="ctx.familyStillCount" class="m-library-badge">{{ ctx.familyStillCount > 99 ? '99+' : ctx.familyStillCount }}</span>
|
||
</button>
|
||
<div class="m-stage-chrome-end">
|
||
<button
|
||
v-if="ctx.selected && ctx.selected.kind!=='track'"
|
||
type="button"
|
||
class="m-icon-btn"
|
||
aria-label="More"
|
||
:aria-expanded="sheet==='actions' ? 'true' : 'false'"
|
||
@click.stop="toggleSheet('actions')"
|
||
>•••</button>
|
||
<button
|
||
v-if="ctx.selected"
|
||
type="button"
|
||
class="m-icon-btn m-eye"
|
||
:aria-label="ctx.eyeOpen ? 'Hide output' : 'Show output'"
|
||
@click.stop="ctx.toggleReveal()"
|
||
>
|
||
<img :src="ctx.eyeOpen ? '/icons/display/eye.svg' : '/icons/display/eye-off.svg'" alt="" width="20" height="20">
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
v-if="(ctx.family || []).length > 1"
|
||
type="button"
|
||
class="m-nav m-nav-prev"
|
||
aria-label="Previous"
|
||
@click.stop="ctx.stepFamily(-1)"
|
||
>‹</button>
|
||
<button
|
||
v-if="(ctx.family || []).length > 1"
|
||
type="button"
|
||
class="m-nav m-nav-next"
|
||
aria-label="Next"
|
||
@click.stop="ctx.stepFamily(1)"
|
||
>›</button>
|
||
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||
</template>
|
||
<template v-else-if="ctx.selected?.kind==='track'">
|
||
<div class="m-audio">
|
||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||
</div>
|
||
</template>
|
||
<video
|
||
v-else-if="ctx.selected?.kind==='clip'"
|
||
:key="ctx.selected.id"
|
||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||
controls
|
||
playsinline
|
||
/>
|
||
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||
<p v-else class="m-muted">Open Library to pick a still or clip</p>
|
||
</div>
|
||
|
||
<Studio2LibraryModal
|
||
:open="!!ctx.libraryOpen"
|
||
:assets="ctx.family || []"
|
||
:selected="ctx.selected"
|
||
:folder-id="ctx.form?.folderId"
|
||
:preview-hidden="!ctx.eyeOpen"
|
||
@close="ctx.closeLibrary()"
|
||
@select="ctx.selectOutput"
|
||
@use-image="ctx.onLibraryUseImage"
|
||
@use-prompt="ctx.onLibraryUsePrompt"
|
||
@copy-prompt="ctx.onLibraryCopyPrompt"
|
||
@copy-raw-prompt="ctx.onLibraryCopyRawPrompt"
|
||
@load-input="ctx.onLibraryLoadInput"
|
||
@load-input-raw="ctx.onLibraryLoadInputRaw"
|
||
@delete="ctx.onLibraryDelete"
|
||
@toggle-preview="ctx.toggleReveal()"
|
||
/>
|
||
|
||
<div class="m-dock">
|
||
<button type="button" :class="{ on: sheet==='slots' }" @click="toggleSheet('slots')">Photos</button>
|
||
<button type="button" :class="{ on: sheet==='prompt' }" @click="toggleSheet('prompt')">Prompt</button>
|
||
<button type="button" :class="{ on: sheet==='look' }" @click="toggleSheet('look')">Look</button>
|
||
<button type="button" :class="{ on: sheet==='gen' }" @click="toggleSheet('gen')">Gen</button>
|
||
<button type="button" class="m-cta" :disabled="ctx.submitting" @click="ctx.queue">{{ ctx.submitting ? '…' : 'Generate' }}</button>
|
||
</div>
|
||
|
||
<!-- Sheets -->
|
||
<div v-if="sheet" class="m-sheet-backdrop" @click="sheet=''" />
|
||
<aside v-if="sheet==='actions'" class="m-sheet m-sheet-actions" role="dialog" aria-label="Still actions">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Actions</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body m-actions-list">
|
||
<button type="button" @click="runAction('use-image')">Use image</button>
|
||
<button type="button" @click="runAction('use-prompt')">Use prompt</button>
|
||
<button type="button" @click="runAction('copy')">Copy prompt</button>
|
||
<button type="button" @click="runAction('copy-raw')">Copy raw prompt</button>
|
||
<button type="button" @click="runAction('load-input')">Load input</button>
|
||
<button type="button" @click="runAction('load-input-raw')">Load input raw</button>
|
||
<button type="button" class="m-danger" @click="runAction('delete')">Delete</button>
|
||
</div>
|
||
</aside>
|
||
<aside v-if="sheet==='slots'" class="m-sheet" role="dialog" aria-label="Photos">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Photos</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body">
|
||
<template v-if="ctx.form.mode==='music' || !ctx.photosPlanState?.show">
|
||
<p class="m-muted">No photos for this task.</p>
|
||
</template>
|
||
<template v-else-if="ctx.photosPlanState.handoffReadonly">
|
||
<p class="m-muted">Continues from last frame</p>
|
||
</template>
|
||
<template v-else>
|
||
<div class="m-photos-head">
|
||
<button type="button" class="m-link" @click="ctx.toggleHideInputs()">
|
||
{{ ctx.hideInputs ? 'Show inputs' : 'Hide inputs' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!ctx.form.photos?.length && ctx.photosPlanState.emptyHint" class="m-muted">{{ ctx.photosPlanState.emptyHint }}</p>
|
||
<Studio2AssetSlot
|
||
v-for="(card, index) in ctx.form.photos"
|
||
:key="'m-photo-'+index+'-'+card.stillId"
|
||
:asset="ctx.stillById(card.stillId)"
|
||
:hidden="!!ctx.hideInputs"
|
||
:role="card.role"
|
||
:role-options="ctx.photoRoleOptions"
|
||
@pick="ctx.setPicker('photo:'+index)"
|
||
@remove="ctx.removePhoto(index)"
|
||
@mention="ctx.mentionPhoto(index)"
|
||
@update:role="ctx.setPhotoRole(index, $event)"
|
||
/>
|
||
<button v-if="(ctx.form.photos?.length||0) < ctx.photosPlanState.max" type="button" class="m-amber" @click="ctx.addPhoto">+ Add photo</button>
|
||
<p v-if="ctx.photoRoleWarning" class="m-hint">{{ ctx.photoRoleWarning }}</p>
|
||
<template v-if="ctx.showIdentityLocks">
|
||
<label class="m-check"><input v-model="ctx.form.lockFace" type="checkbox"> Lock face</label>
|
||
<label class="m-check"><input v-model="ctx.form.lockOutfit" type="checkbox"> Lock outfit</label>
|
||
</template>
|
||
<p v-if="ctx.uploading || ctx.uploadMessage" class="m-amber">{{ ctx.uploading?'Uploading…':ctx.uploadMessage }}</p>
|
||
</template>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside v-if="sheet==='prompt'" class="m-sheet" role="dialog" aria-label="Prompt">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Prompt</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body">
|
||
<template v-if="ctx.form.mode==='music'">
|
||
<label class="m-label">Genre / style tags<textarea v-model="ctx.music.tags" rows="3" class="m-area" /></label>
|
||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||
</template>
|
||
<template v-else>
|
||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||
<div>
|
||
<span class="m-label">Negative prompt</span>
|
||
<div class="m-neg">
|
||
<span v-for="chip in ctx.negativeChips" :key="chip" class="m-neg-chip">
|
||
{{ chip }}
|
||
<button type="button" :aria-label="'Remove '+chip" @click="ctx.removeNegative(chip)">×</button>
|
||
</span>
|
||
</div>
|
||
<div class="m-neg-add">
|
||
<input
|
||
:value="ctx.negativeDraft"
|
||
type="text"
|
||
class="m-input"
|
||
placeholder="Add negative chip"
|
||
@input="ctx.setNegativeDraft(($event.target as HTMLInputElement).value)"
|
||
@keydown.enter.prevent="ctx.addNegative"
|
||
>
|
||
<button type="button" @click="ctx.addNegative">Add</button>
|
||
</div>
|
||
</div>
|
||
<Studio2StyleChips v-if="!ctx.video" v-model="ctx.form.imageStyles" />
|
||
</template>
|
||
<p v-if="ctx.error" class="m-err">{{ ctx.error }}</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside v-if="sheet==='look'" class="m-sheet" role="dialog" aria-label="Look">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Look</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body">
|
||
<template v-if="ctx.form.mode==='music'">
|
||
<label class="m-label">Seed<input v-model="ctx.music.seed" class="m-input" placeholder="random"></label>
|
||
<label class="m-label">Track name<input v-model="ctx.music.name" class="m-input" placeholder="Optional"></label>
|
||
</template>
|
||
<template v-else>
|
||
<div>
|
||
<span class="m-label">LoRA</span>
|
||
<LoraSelect v-model="ctx.form.settings.loraStack" compact :names="ctx.pickerLoras" :use="ctx.currentLoraUse" :clip-strength="ctx.form.engine!=='qwen21' && !ctx.video" filename-subtitle :aliases="ctx.loraAliases" :triggers="ctx.loraTriggers" missing-label="None" @insert-trigger="ctx.form.promptSections.action += ' '+$event" />
|
||
</div>
|
||
<label class="m-label">Seed
|
||
<div class="m-seed">
|
||
<input
|
||
:value="ctx.form.settings.seed ?? ''"
|
||
type="number"
|
||
min="0"
|
||
max="2147483647"
|
||
class="m-input"
|
||
@input="ctx.form.settings.seed=Number(($event.target as HTMLInputElement).value);ctx.form.settings.seedMode='locked'"
|
||
>
|
||
<button type="button" class="m-dice" aria-label="Random seed" @click="ctx.randomizeSeed">🎲</button>
|
||
</div>
|
||
</label>
|
||
<label class="m-label">Aspect
|
||
<select v-model="ctx.form.settings.aspect" class="m-input">
|
||
<option v-for="opt in ctx.aspectOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</option>
|
||
</select>
|
||
</label>
|
||
<label class="m-label">Steps<input v-model.number="ctx.form.settings.steps" type="number" class="m-input"></label>
|
||
<label class="m-label">CFG<input v-model.number="ctx.form.settings.cfg" type="number" class="m-input"></label>
|
||
</template>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside v-if="sheet==='gen'" class="m-sheet" role="dialog" aria-label="Gen extras">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Gen</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body">
|
||
<template v-if="ctx.form.mode==='music'">
|
||
<label class="m-label">Target length (sec)<input v-model.number="ctx.music.duration" type="number" min="15" max="150" class="m-input"></label>
|
||
<p class="m-hint">Caps semantic tokens (~25/s). Actual length follows the model end token.</p>
|
||
</template>
|
||
<template v-else-if="ctx.video">
|
||
<label class="m-label">Duration (sec)<input v-model.number="ctx.form.settings.duration" type="number" class="m-input"></label>
|
||
<label class="m-label">FPS<input v-model.number="ctx.form.settings.fps" type="number" class="m-input"></label>
|
||
<label class="m-check"><input v-model="ctx.form.settings.turbo" type="checkbox"> Turbo</label>
|
||
<Studio2PromptControls v-model="ctx.form.promptSections" />
|
||
<details class="m-details">
|
||
<summary>Queue more shots</summary>
|
||
<textarea :value="ctx.batchText" rows="4" class="m-area" placeholder="First additional prompt Next" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||
</details>
|
||
</template>
|
||
<template v-else>
|
||
<p class="m-muted">Image extras live in Look (steps / CFG / seed / aspect).</p>
|
||
<details v-if="ctx.form.mode==='edit'" class="m-details">
|
||
<summary>Queue extra edit passes</summary>
|
||
<textarea :value="ctx.batchText" rows="4" class="m-area" @input="ctx.setBatchText(($event.target as HTMLTextAreaElement).value)" />
|
||
</details>
|
||
</template>
|
||
</div>
|
||
</aside>
|
||
|
||
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||
<div class="m-sheet-body">
|
||
<div
|
||
v-for="card in ctx.queueCards"
|
||
:key="card.id"
|
||
class="m-job"
|
||
:class="{ on: ctx.focusedJobId===card.id }"
|
||
@click="ctx.focusQueueCard(card)"
|
||
>
|
||
<div class="m-job-id">{{ card.code }}</div>
|
||
<div class="m-job-title">{{ card.title }}</div>
|
||
<div class="m-muted">{{ card.meta }}</div>
|
||
<div class="m-job-status">
|
||
<span>● {{ card.statusLabel }}</span>
|
||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||
</div>
|
||
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||
<progress
|
||
class="m-job-progress-bar"
|
||
max="100"
|
||
:value="card.progress.indeterminate ? undefined : card.progress.percent"
|
||
/>
|
||
<span v-if="card.elapsed" class="m-muted">{{ card.elapsed }}</span>
|
||
</div>
|
||
</div>
|
||
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
export type Studio2MobileCtx = Record<string, any>
|
||
|
||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||
const sheet = ref('')
|
||
const touchStart = ref<{ x: number; y: number } | null>(null)
|
||
|
||
function toggleSheet(id: string) {
|
||
sheet.value = sheet.value === id ? '' : id
|
||
}
|
||
function closeMenus() {
|
||
sheet.value = ''
|
||
}
|
||
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
||
sheet.value = ''
|
||
if (kind === 'use-image') props.ctx.applyUseImage?.()
|
||
else if (kind === 'use-prompt') props.ctx.applyUsePrompt?.()
|
||
else if (kind === 'copy') props.ctx.copySelectedPrompt?.()
|
||
else if (kind === 'copy-raw') props.ctx.copySelectedRawPrompt?.()
|
||
else if (kind === 'load-input') props.ctx.loadInputUsed?.()
|
||
else if (kind === 'load-input-raw') props.ctx.loadInputRaw?.()
|
||
else if (kind === 'delete') props.ctx.deleteOutput?.()
|
||
}
|
||
function onStageTouchStart(e: TouchEvent) {
|
||
const t = e.changedTouches[0]
|
||
if (!t) return
|
||
touchStart.value = { x: t.clientX, y: t.clientY }
|
||
}
|
||
function onStageTouchEnd(e: TouchEvent) {
|
||
const start = touchStart.value
|
||
touchStart.value = null
|
||
const t = e.changedTouches[0]
|
||
if (!start || !t || (props.ctx.family || []).length < 2) return
|
||
const dx = t.clientX - start.x
|
||
const dy = t.clientY - start.y
|
||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.4) return
|
||
props.ctx.stepFamily?.(dx < 0 ? 1 : -1)
|
||
}
|
||
watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.m-shell {
|
||
--bg: #0b0b0d;
|
||
--panel: #141416;
|
||
--line: #2a2a2e;
|
||
--amber: #f5a524;
|
||
--amber-on: #1a1204;
|
||
--text: #f3f1ea;
|
||
--muted: #9a9588;
|
||
position: relative;
|
||
display: flex;
|
||
height: 100dvh;
|
||
max-height: 100dvh;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: 14px ui-sans-serif, system-ui, sans-serif;
|
||
}
|
||
.m-header {
|
||
display: flex;
|
||
height: 48px;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
gap: 8px;
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 0 12px;
|
||
}
|
||
.m-brand { font-weight: 700; }
|
||
.m-crumb select {
|
||
max-width: 7rem;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.m-grow { flex: 1; }
|
||
.m-gpu {
|
||
border: 1px solid #2d4a2a;
|
||
border-radius: 999px;
|
||
padding: 3px 8px;
|
||
color: #b8e0a8;
|
||
font-size: 11px;
|
||
}
|
||
.m-gpu[data-tone='busy'] { border-color: color-mix(in srgb, var(--amber) 40%, transparent); color: var(--amber); }
|
||
.m-chips {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
padding: 8px 12px;
|
||
}
|
||
.m-chip {
|
||
flex: 0 0 auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #1a1a1d;
|
||
padding: 6px 10px;
|
||
color: var(--muted);
|
||
font: inherit;
|
||
}
|
||
.m-chip.on, .m-dock button.on {
|
||
border-color: var(--amber);
|
||
background: var(--amber);
|
||
color: var(--amber-on);
|
||
font-weight: 700;
|
||
}
|
||
.m-chip:disabled { opacity: 0.45; }
|
||
.m-more { position: relative; }
|
||
.m-more summary { list-style: none; cursor: pointer; padding: 4px 8px; color: var(--muted); }
|
||
.m-more-menu {
|
||
position: absolute;
|
||
right: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
min-width: 8rem;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
padding: 8px;
|
||
}
|
||
.m-more-menu a { color: var(--text); text-decoration: none; padding: 6px 8px; }
|
||
.m-stage {
|
||
position: relative;
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
background: #080808;
|
||
}
|
||
.m-stage > img,
|
||
.m-stage > video {
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||
.m-stage-chrome {
|
||
position: absolute;
|
||
top: calc(8px + env(safe-area-inset-top, 0px));
|
||
right: 8px;
|
||
left: 8px;
|
||
z-index: 5;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
pointer-events: none;
|
||
}
|
||
.m-stage-chrome > *,
|
||
.m-stage-chrome-end {
|
||
pointer-events: auto;
|
||
}
|
||
.m-stage-chrome-end {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.m-icon-btn {
|
||
position: relative;
|
||
display: grid;
|
||
width: 44px;
|
||
height: 44px;
|
||
flex-shrink: 0;
|
||
place-items: center;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #000a;
|
||
color: var(--text);
|
||
font: inherit;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
}
|
||
.m-library-badge {
|
||
position: absolute;
|
||
top: -4px;
|
||
right: -4px;
|
||
min-width: 18px;
|
||
border-radius: 999px;
|
||
background: var(--amber);
|
||
color: var(--amber-on);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
line-height: 16px;
|
||
padding: 0 4px;
|
||
text-align: center;
|
||
}
|
||
.m-hidden-note {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
}
|
||
.m-nav {
|
||
position: absolute;
|
||
top: 50%;
|
||
z-index: 4;
|
||
display: grid;
|
||
width: 44px;
|
||
height: 44px;
|
||
place-items: center;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #000a;
|
||
color: var(--text);
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
opacity: 0.7;
|
||
transform: translateY(-50%);
|
||
}
|
||
.m-nav-prev { left: 8px; }
|
||
.m-nav-next { right: 8px; }
|
||
.m-photos-head { margin-bottom: 8px; }
|
||
.m-sheet-actions {
|
||
max-height: min(50vh, 100%);
|
||
}
|
||
.m-actions-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.m-actions-list button {
|
||
min-height: 48px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #1a1a1d;
|
||
color: var(--text);
|
||
font: inherit;
|
||
text-align: left;
|
||
padding: 0 14px;
|
||
}
|
||
.m-actions-list .m-danger { color: #fca5a5; }
|
||
.m-dock {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr) 120px;
|
||
flex-shrink: 0;
|
||
gap: 6px;
|
||
border-top: 1px solid var(--line);
|
||
background: var(--panel);
|
||
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
||
}
|
||
.m-dock button {
|
||
height: 48px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #1a1a1d;
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
.m-cta {
|
||
background: var(--amber) !important;
|
||
color: var(--amber-on) !important;
|
||
font-weight: 700 !important;
|
||
}
|
||
.m-cta:disabled { opacity: 0.5; }
|
||
.m-sheet-backdrop {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 30;
|
||
background: #0008;
|
||
}
|
||
.m-sheet {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
z-index: 31;
|
||
display: flex;
|
||
max-height: min(85vh, 100%);
|
||
flex-direction: column;
|
||
border-top: 1px solid var(--line);
|
||
border-radius: 16px 16px 0 0;
|
||
background: var(--panel);
|
||
padding: 10px 14px 20px;
|
||
}
|
||
.m-handle {
|
||
display: block;
|
||
width: 40px;
|
||
height: 4px;
|
||
margin: 4px auto 12px;
|
||
border: 0;
|
||
border-radius: 4px;
|
||
background: #444;
|
||
}
|
||
.m-sheet-head {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
.m-sheet-head button { border: 0; background: transparent; color: var(--amber); }
|
||
.m-sheet-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||
.m-area, .m-input {
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #1a1a1d;
|
||
padding: 8px;
|
||
color: var(--text);
|
||
font: inherit;
|
||
}
|
||
.m-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||
.m-hint, .m-muted { color: var(--muted); font-size: 12px; }
|
||
.m-amber { color: var(--amber); font-size: 12px; }
|
||
.m-err { color: #fca5a5; font-size: 12px; }
|
||
.m-neg { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||
.m-neg-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
border: 1px solid #5a4a28;
|
||
border-radius: 999px;
|
||
background: #2a2418;
|
||
padding: 4px 10px;
|
||
color: #e8c37a;
|
||
font-size: 12px;
|
||
}
|
||
.m-neg-chip button { border: 0; background: transparent; color: inherit; }
|
||
.m-neg-add { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||
.m-neg-add .m-input { flex: 1; margin-top: 0; }
|
||
.m-seed { display: flex; gap: 8px; margin-top: 4px; }
|
||
.m-seed .m-input { margin-top: 0; flex: 1; }
|
||
.m-dice {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: #1a1a1d;
|
||
}
|
||
.m-details { border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
|
||
.m-job {
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.m-job.on { border-color: var(--amber); border-width: 2px; }
|
||
.m-job-id { color: var(--muted); font-size: 12px; }
|
||
.m-job-title { font-weight: 600; }
|
||
.m-job-status { display: flex; justify-content: space-between; margin-top: 6px; font-size: 12px; }
|
||
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
||
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||
</style>
|