v0.4
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Image Decks — Orison Reach</title>
|
||||
<style>
|
||||
:root {
|
||||
--void:#171513;--void-soft:#211e1a;--panel:#2a2520;--paper:#e8dcc4;
|
||||
--ink:#2c231c;--text:#e9dfca;--muted:#a89d89;--brass:#b88a43;
|
||||
--rust:#94452f;--moss:#70794e;--oxygen:#86a5a4;--line:rgba(232,220,196,.18);
|
||||
--serif:Georgia,"Times New Roman",serif;
|
||||
--sans:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
|
||||
--mono:"SFMono-Regular",Consolas,"Liberation Mono",monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0;color:var(--text);
|
||||
background:radial-gradient(circle at 12% 8%,rgba(134,165,164,.08),transparent 26rem),
|
||||
linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(255,255,255,.014) 1px,transparent 1px),var(--void);
|
||||
background-size:auto,28px 28px,28px 28px,auto;
|
||||
font-family:var(--sans);line-height:1.7;}
|
||||
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.16;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
|
||||
mix-blend-mode:soft-light;z-index:10;}
|
||||
a{color:inherit;}
|
||||
.shell{width:min(1180px,calc(100% - 36px));margin:0 auto;}
|
||||
nav{position:sticky;top:0;z-index:5;border-bottom:1px solid var(--line);
|
||||
background:rgba(23,21,19,.86);backdrop-filter:blur(14px);}
|
||||
nav .shell{display:flex;gap:1.6rem;align-items:center;}
|
||||
nav a{padding:1rem 0;text-decoration:none;white-space:nowrap;
|
||||
font:650 .72rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
|
||||
nav a:hover,nav a.active{color:var(--paper);}
|
||||
main{padding:4rem 0 7rem;}
|
||||
h2{font-family:var(--serif);font-size:clamp(2.2rem,5vw,4.2rem);font-weight:600;
|
||||
color:var(--paper);letter-spacing:-.02em;line-height:1.05;margin:0;}
|
||||
.eyebrow{color:var(--brass);font:700 .74rem/1.2 var(--mono);letter-spacing:.22em;text-transform:uppercase;margin-bottom:.4rem;}
|
||||
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.55rem 1.1rem;
|
||||
border:1px solid var(--line);background:transparent;color:var(--brass);
|
||||
font:700 .68rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
||||
cursor:pointer;transition:background .15s,border-color .15s;white-space:nowrap;}
|
||||
.btn:hover{background:rgba(184,138,67,.1);border-color:var(--brass);}
|
||||
.btn.primary{background:var(--brass);color:var(--void);border-color:var(--brass);}
|
||||
.btn.primary:hover{background:#cca055;}
|
||||
.btn.sm{padding:.35rem .7rem;font-size:.62rem;}
|
||||
.btn.danger{color:var(--rust);}
|
||||
.btn.danger:hover{background:rgba(148,69,47,.15);border-color:var(--rust);}
|
||||
.btn.ghost{color:var(--muted);}
|
||||
.btn.ghost:hover{color:var(--text);}
|
||||
|
||||
.layout{display:grid;grid-template-columns:260px 1fr;gap:2.5rem;margin-top:3rem;align-items:start;}
|
||||
@media(max-width:700px){.layout{grid-template-columns:1fr;}}
|
||||
|
||||
/* Deck list */
|
||||
.deck-list{display:flex;flex-direction:column;gap:1px;}
|
||||
.deck-row{padding:.9rem 1.1rem;background:var(--void-soft);border:1px solid var(--line);
|
||||
cursor:pointer;transition:border-color .12s;}
|
||||
.deck-row.active,.deck-row:hover{border-color:rgba(184,138,67,.4);}
|
||||
.deck-row.active{background:rgba(184,138,67,.06);}
|
||||
.deck-name{font:600 .95rem/1.2 var(--serif);color:var(--paper);}
|
||||
.deck-meta{font:.62rem/1 var(--mono);color:var(--muted);margin-top:.2rem;}
|
||||
|
||||
/* Tabs */
|
||||
.tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin-bottom:1.5rem;}
|
||||
.tab{padding:.6rem 1.2rem;font:700 .68rem/1 var(--mono);letter-spacing:.12em;
|
||||
text-transform:uppercase;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;
|
||||
margin-bottom:-1px;transition:color .12s;}
|
||||
.tab.active{color:var(--brass);border-bottom-color:var(--brass);}
|
||||
.tab:hover{color:var(--text);}
|
||||
|
||||
/* Cards list */
|
||||
.card-list{display:flex;flex-direction:column;gap:1px;}
|
||||
.card-item{background:var(--void-soft);border:1px solid var(--line);}
|
||||
.card-header{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.8rem 1rem;cursor:pointer;gap:1rem;}
|
||||
.card-header:hover{background:rgba(255,255,255,.02);}
|
||||
.card-item-title{font:600 1rem/1.2 var(--serif);color:var(--paper);}
|
||||
.card-item-type{font:.6rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
|
||||
color:var(--muted);margin-top:.15rem;}
|
||||
.card-body{padding:1rem;border-top:1px solid var(--line);}
|
||||
|
||||
/* Card form */
|
||||
.form-group{margin-bottom:1rem;}
|
||||
.form-label{display:block;font:700 .65rem/1 var(--mono);letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--brass);margin-bottom:.38rem;}
|
||||
input[type=text],textarea,select{width:100%;background:var(--panel);
|
||||
border:1px solid rgba(232,220,196,.2);color:var(--text);
|
||||
padding:.6rem .8rem;font-family:var(--sans);font-size:.9rem;
|
||||
appearance:none;-webkit-appearance:none;}
|
||||
select option{background:var(--panel);}
|
||||
textarea{resize:vertical;min-height:72px;}
|
||||
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--brass);}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
|
||||
.check-row{display:flex;gap:1.2rem;flex-wrap:wrap;}
|
||||
.check-label{display:flex;align-items:center;gap:.5rem;font:.8rem/1 var(--mono);color:var(--text);cursor:pointer;}
|
||||
.check-label input{width:auto;}
|
||||
|
||||
/* Card images */
|
||||
.card-img-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.7rem;margin-top:.8rem;}
|
||||
.card-img-wrap{position:relative;}
|
||||
.card-img-wrap img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;border:1px solid var(--line);}
|
||||
.card-img-del{position:absolute;top:.25rem;right:.25rem;background:rgba(23,21,19,.8);
|
||||
border:none;color:var(--rust);font:.6rem/1 var(--mono);padding:.2rem .4rem;cursor:pointer;}
|
||||
.card-img-del:hover{background:var(--rust);color:var(--paper);}
|
||||
.card-img-upload{border:1px dashed rgba(184,138,67,.3);aspect-ratio:4/3;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:.3rem;cursor:pointer;transition:border-color .12s;font:.65rem/1.3 var(--mono);
|
||||
color:var(--muted);text-align:center;padding:.5rem;}
|
||||
.card-img-upload:hover{border-color:rgba(184,138,67,.6);color:var(--brass);}
|
||||
|
||||
/* Pool images */
|
||||
.img-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:1rem;}
|
||||
.img-card{background:var(--void-soft);border:1px solid var(--line);overflow:hidden;}
|
||||
.img-thumb{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;}
|
||||
.img-body{padding:.55rem .75rem .7rem;}
|
||||
.img-tags{display:flex;flex-wrap:wrap;gap:.25rem;margin-bottom:.4rem;}
|
||||
.tag{font:700 .57rem/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;
|
||||
padding:.18rem .45rem;background:rgba(184,138,67,.1);color:var(--brass);
|
||||
border:1px solid rgba(184,138,67,.22);cursor:pointer;}
|
||||
.tag:hover{background:rgba(148,69,47,.15);color:var(--rust);}
|
||||
.tag-input{width:100%;background:var(--panel);border:1px solid rgba(232,220,196,.18);
|
||||
color:var(--text);padding:.32rem .5rem;font:.75rem/1 var(--mono);outline:none;margin-bottom:.4rem;}
|
||||
.tag-input:focus{border-color:var(--brass);}
|
||||
|
||||
/* Upload zone */
|
||||
.upload-zone{border:2px dashed rgba(184,138,67,.28);padding:1.8rem;text-align:center;
|
||||
cursor:pointer;transition:border-color .15s;margin-bottom:1.2rem;}
|
||||
.upload-zone:hover{border-color:rgba(184,138,67,.6);}
|
||||
.upload-label{font:.75rem/1.5 var(--mono);color:var(--muted);display:block;margin-top:.35rem;}
|
||||
|
||||
.empty{padding:2.5rem 0;color:var(--muted);font:italic 1rem/1.6 var(--serif);}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(23,21,19,.88);
|
||||
backdrop-filter:blur(6px);z-index:50;display:flex;align-items:center;
|
||||
justify-content:center;padding:1rem;}
|
||||
.modal{background:var(--void-soft);border:1px solid rgba(232,220,196,.3);
|
||||
padding:1.8rem;width:min(480px,100%);max-height:90vh;overflow-y:auto;}
|
||||
.modal-title{font:700 1.4rem/1.1 var(--serif);color:var(--paper);margin:0 0 1.3rem;}
|
||||
.modal-actions{display:flex;justify-content:flex-end;gap:.7rem;margin-top:1.3rem;
|
||||
padding-top:1rem;border-top:1px solid var(--line);}
|
||||
.form-error{font:.72rem/1.4 var(--mono);color:var(--rust);margin-top:.35rem;}
|
||||
|
||||
footer{border-top:1px solid var(--line);padding:2.5rem 0 4rem;color:var(--muted);}
|
||||
footer p{margin:0;font:.72rem/1.7 var(--mono);letter-spacing:.08em;text-transform:uppercase;}
|
||||
[x-cloak]{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="deckApp()" x-cloak>
|
||||
<!-- Login overlay -->
|
||||
<div x-show="needsAuth" style="position:fixed;inset:0;background:rgba(23,21,19,.95);backdrop-filter:blur(8px);z-index:100;display:flex;align-items:center;justify-content:center;padding:1rem">
|
||||
<div style="background:var(--void-soft);border:1px solid rgba(232,220,196,.3);padding:2rem;width:min(400px,100%)">
|
||||
<div style="font:700 1.4rem/1.1 var(--serif);color:var(--paper);margin:0 0 1.4rem">Deck Editor Access</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Admin Password</label>
|
||||
<input type="password" x-model="loginPw" @keydown.enter="login()" placeholder="Enter admin password" style="width:100%;background:var(--panel);border:1px solid rgba(232,220,196,.2);color:var(--text);padding:.6rem .8rem;font-family:var(--sans);font-size:.9rem" />
|
||||
</div>
|
||||
<div x-show="loginErr" x-text="loginErr" style="font:.75rem/1.4 var(--mono);color:var(--rust);margin-bottom:.8rem"></div>
|
||||
<button class="btn primary" @click="login()" :disabled="loginSaving" style="width:100%">
|
||||
<span x-text="loginSaving ? 'Checking…' : 'Enter'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="shell">
|
||||
<a href="index.html">Archive</a>
|
||||
<a href="games.html">Sessions</a>
|
||||
<a href="deck-editor.html" class="active">Image Decks</a>
|
||||
<button x-show="adminToken && !needsAuth" class="btn sm ghost" @click="logout()" style="margin-left:auto">Log out</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="shell">
|
||||
<div>
|
||||
<div class="eyebrow">Archive // Card & Image Library</div>
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;flex-wrap:wrap;margin-top:.4rem">
|
||||
<h2>Decks</h2>
|
||||
<button class="btn primary" @click="openNewDeck()">+ New Deck</button>
|
||||
</div>
|
||||
<p style="color:var(--muted);max-width:64ch;margin:.8rem 0 0;font-size:.9rem">
|
||||
Create decks with custom cards and images. Images tagged with keywords auto-match events in any session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Deck sidebar -->
|
||||
<div>
|
||||
<div style="font:700 .63rem/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:.7rem">Decks</div>
|
||||
<div class="deck-list">
|
||||
<div x-show="!decks.length" class="empty" style="padding:1rem 0">No decks yet.</div>
|
||||
<template x-for="d in decks" :key="d.id">
|
||||
<div class="deck-row" :class="sel?.id===d.id?'active':''" @click="sel=d">
|
||||
<div class="deck-name" x-text="d.name"></div>
|
||||
<div class="deck-meta">
|
||||
<span x-text="(d.cards?.length||0)+' cards'"></span> ·
|
||||
<span x-text="(d.images?.length||0)+' images'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deck detail -->
|
||||
<div>
|
||||
<div x-show="!sel" class="empty">Select or create a deck.</div>
|
||||
<div x-show="sel">
|
||||
<!-- Deck header -->
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem">
|
||||
<div>
|
||||
<div style="font:700 1.5rem/1.1 var(--serif);color:var(--paper)" x-text="sel?.name"></div>
|
||||
<div style="font:.85rem/1.5 var(--sans);color:var(--muted);margin-top:.2rem" x-text="sel?.description||'No description.'"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button class="btn sm ghost" @click="openEditDeck()">Edit</button>
|
||||
<button class="btn sm danger" @click="deleteDeck()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="tab" :class="tab==='cards'?'active':''" @click="tab='cards'">
|
||||
Cards (<span x-text="sel?.cards?.length||0"></span>)
|
||||
</div>
|
||||
<div class="tab" :class="tab==='images'?'active':''" @click="tab='images'">
|
||||
Image Pool (<span x-text="sel?.images?.length||0"></span>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CARDS TAB ─────────────────────────────────── -->
|
||||
<div x-show="tab==='cards'">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:1rem">
|
||||
<button class="btn sm" @click="openNewCard()">+ Add Card</button>
|
||||
</div>
|
||||
<div x-show="!sel?.cards?.length" class="empty">No cards yet. Add the first one.</div>
|
||||
<div class="card-list">
|
||||
<template x-for="card in sel?.cards||[]" :key="card.id">
|
||||
<div class="card-item">
|
||||
<div class="card-header" @click="expandedCard = expandedCard===card.id ? null : card.id">
|
||||
<div>
|
||||
<div class="card-item-title" x-text="card.title"></div>
|
||||
<div class="card-item-type" x-text="(card.type||'action').toUpperCase() + (card.images?.length ? ' · '+card.images.length+' image'+(card.images.length>1?'s':'') : '')"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;flex-shrink:0">
|
||||
<button class="btn sm ghost" @click.stop="openEditCard(card)">Edit</button>
|
||||
<button class="btn sm danger" @click.stop="deleteCard(card)">Delete</button>
|
||||
<span style="font:.7rem/1 var(--mono);color:var(--muted);margin-left:.2rem;line-height:2" x-text="expandedCard===card.id?'▲':'▼'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded: show text + images -->
|
||||
<div class="card-body" x-show="expandedCard===card.id">
|
||||
<div style="font:.72rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--brass);margin-bottom:.5rem">A —</div>
|
||||
<div style="font:.9rem/1.6 var(--sans);color:var(--text);margin-bottom:.8rem" x-text="card.a"></div>
|
||||
<div style="font:.72rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--brass);margin-bottom:.5rem">B —</div>
|
||||
<div style="font:.9rem/1.6 var(--sans);color:var(--text);margin-bottom:1rem" x-text="card.b"></div>
|
||||
<!-- Card images -->
|
||||
<div style="font:.63rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:.5rem">Card Images</div>
|
||||
<div class="card-img-grid">
|
||||
<template x-for="img in card.images||[]" :key="img.id">
|
||||
<div class="card-img-wrap">
|
||||
<img :src="'assets/deck-images/'+img.filename" loading="lazy" />
|
||||
<button class="card-img-del" @click="deleteCardImage(card, img)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-img-upload" @click="triggerCardImageUpload(card)">
|
||||
<span>+</span>
|
||||
<span>Upload image</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" :id="'ci-'+card.id" accept="image/*" multiple style="display:none"
|
||||
@change="handleCardImages(card, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── IMAGE POOL TAB ────────────────────────────── -->
|
||||
<div x-show="tab==='images'">
|
||||
<div class="upload-zone" @click="$refs.poolInput.click()" @dragover.prevent @drop.prevent="handlePoolDrop($event)">
|
||||
<div style="font:700 .7rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--brass)">
|
||||
Drop images or click to upload
|
||||
</div>
|
||||
<span class="upload-label">These images auto-match events by keyword. Tag each one after upload.</span>
|
||||
<div x-show="uploading" style="font:.72rem/1 var(--mono);color:var(--brass);margin-top:.4rem" x-text="'Uploading '+uploadProg+'…'"></div>
|
||||
</div>
|
||||
<input type="file" x-ref="poolInput" multiple accept="image/*" style="display:none" @change="handlePoolFiles($event)" />
|
||||
|
||||
<div x-show="!sel?.images?.length" class="empty">No images in pool.</div>
|
||||
<div class="img-grid">
|
||||
<template x-for="img in sel?.images||[]" :key="img.id">
|
||||
<div class="img-card">
|
||||
<img class="img-thumb" :src="'assets/deck-images/'+img.filename" loading="lazy" />
|
||||
<div class="img-body">
|
||||
<div class="img-tags">
|
||||
<template x-for="kw in img.keywords" :key="kw">
|
||||
<span class="tag" @click="removePoolKw(img,kw)" :title="'Remove '+kw" x-text="kw"></span>
|
||||
</template>
|
||||
</div>
|
||||
<input class="tag-input" type="text" placeholder="add keyword + Enter"
|
||||
@keydown.enter.prevent="addPoolKw(img,$event.target);$event.target.value=''"
|
||||
@keydown.comma.prevent="addPoolKw(img,$event.target);$event.target.value=''" />
|
||||
<button class="btn sm danger" @click="deletePoolImage(img)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /sel -->
|
||||
</div>
|
||||
</div><!-- /layout -->
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="shell" style="display:flex;justify-content:space-between;gap:2rem;flex-wrap:wrap">
|
||||
<p>Orison Reach Public Archive<br />Civilian access edition</p>
|
||||
<p>Knowledge builds tomorrow<br />Work keeps it standing</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Deck modal -->
|
||||
<div class="modal-overlay" x-show="showDeckModal" @click.self="showDeckModal=false" @keydown.escape.window="showDeckModal=false">
|
||||
<div class="modal">
|
||||
<div class="modal-title" x-text="editDeckId?'Edit Deck':'New Deck'"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name *</label>
|
||||
<input type="text" x-model="deckForm.name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea x-model="deckForm.description"></textarea>
|
||||
</div>
|
||||
<div class="form-error" x-show="deckErr" x-text="deckErr"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn ghost" @click="showDeckModal=false">Cancel</button>
|
||||
<button class="btn primary" @click="saveDeck()" :disabled="saving" x-text="saving?'Saving…':(editDeckId?'Save':'Create')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card modal -->
|
||||
<div class="modal-overlay" x-show="showCardModal" @click.self="showCardModal=false" @keydown.escape.window="showCardModal=false">
|
||||
<div class="modal">
|
||||
<div class="modal-title" x-text="editCardId?'Edit Card':'New Card'"></div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Title *</label>
|
||||
<input type="text" x-model="cardForm.title" placeholder="e.g. The Discovery" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Type</label>
|
||||
<div style="display:flex;gap:1.2rem">
|
||||
<label class="check-label"><input type="radio" x-model="cardForm.type" value="action" /><span>Action</span></label>
|
||||
<label class="check-label"><input type="radio" x-model="cardForm.type" value="question" /><span>Question</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" x-show="cardForm.type==='question'">
|
||||
<label class="form-label">Question</label>
|
||||
<textarea x-model="cardForm.question" placeholder="What does this ask the players?" style="min-height:60px"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Option A *</label>
|
||||
<textarea x-model="cardForm.a" placeholder="First choice or instruction…"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Option B *</label>
|
||||
<textarea x-model="cardForm.b" placeholder="Second choice or instruction…"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Only draw when…</label>
|
||||
<div class="check-row">
|
||||
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('cast')" @change="toggleReq('cast',$event)" /><span>Cast exists</span></label>
|
||||
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('keyword')" @change="toggleReq('keyword',$event)" /><span>Keywords exist</span></label>
|
||||
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('project')" @change="toggleReq('project',$event)" /><span>Projects exist</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-error" x-show="cardErr" x-text="cardErr"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn ghost" @click="showCardModal=false">Cancel</button>
|
||||
<button class="btn primary" @click="saveCard()" :disabled="saving" x-text="saving?'Saving…':(editCardId?'Save':'Add Card')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||
<script>
|
||||
function deckApp() {
|
||||
return {
|
||||
adminToken: localStorage.getItem('deckAdminToken') || '',
|
||||
needsAuth: false,
|
||||
loginPw: '', loginErr: null, loginSaving: false,
|
||||
decks: [], sel: null, tab: 'cards',
|
||||
expandedCard: null,
|
||||
showDeckModal: false, editDeckId: null,
|
||||
deckForm: { name: '', description: '' }, deckErr: null,
|
||||
showCardModal: false, editCardId: null,
|
||||
cardForm: { title: '', type: 'action', question: '', a: '', b: '', requires: [] },
|
||||
cardErr: null,
|
||||
saving: false, uploading: false, uploadProg: '',
|
||||
|
||||
async init() {
|
||||
// Check if auth is required
|
||||
const r = await fetch('/api/admin/login', { method: 'POST',
|
||||
headers: this.adminHeaders(), body: JSON.stringify({password:''}) })
|
||||
if (r.status === 401) {
|
||||
// Server has a password set — check our stored token
|
||||
const check = await fetch('/api/decks', { headers: {'x-admin-token': this.adminToken} })
|
||||
this.needsAuth = check.status === 401
|
||||
}
|
||||
if (!this.needsAuth) await this.load()
|
||||
},
|
||||
|
||||
async login() {
|
||||
this.loginSaving = true; this.loginErr = null
|
||||
const r = await fetch('/api/admin/login', { method: 'POST',
|
||||
headers: this.adminHeaders(), body: JSON.stringify({password: this.loginPw}) })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { this.loginErr = 'Wrong password'; this.loginSaving = false; return }
|
||||
this.adminToken = d.token
|
||||
localStorage.setItem('deckAdminToken', d.token)
|
||||
this.needsAuth = false; this.loginSaving = false
|
||||
await this.load()
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.adminToken = ''; localStorage.removeItem('deckAdminToken'); this.needsAuth = true
|
||||
},
|
||||
|
||||
adminHeaders() {
|
||||
return { 'Content-Type': 'application/json', 'x-admin-token': this.adminToken }
|
||||
},
|
||||
|
||||
async load() {
|
||||
const r = await fetch('/api/decks')
|
||||
this.decks = r.ok ? await r.json() : []
|
||||
if (this.sel) this.sel = this.decks.find(d => d.id === this.sel.id) || null
|
||||
},
|
||||
|
||||
// ── Deck CRUD ────────────────────────────────────────
|
||||
openNewDeck() {
|
||||
this.editDeckId = null; this.deckForm = { name: '', description: '' }
|
||||
this.deckErr = null; this.showDeckModal = true
|
||||
},
|
||||
openEditDeck() {
|
||||
if (!this.sel) return
|
||||
this.editDeckId = this.sel.id
|
||||
this.deckForm = { name: this.sel.name, description: this.sel.description }
|
||||
this.deckErr = null; this.showDeckModal = true
|
||||
},
|
||||
async saveDeck() {
|
||||
if (!this.deckForm.name) { this.deckErr = 'Name required'; return }
|
||||
this.saving = true
|
||||
try {
|
||||
const url = this.editDeckId ? '/api/decks/'+this.editDeckId : '/api/decks'
|
||||
const r = await fetch(url, { method: this.editDeckId?'PUT':'POST',
|
||||
headers: this.adminHeaders(), body: JSON.stringify(this.deckForm) })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { this.deckErr = d.error; return }
|
||||
await this.load()
|
||||
if (!this.editDeckId) this.sel = this.decks.find(x => x.id === d.id)
|
||||
this.showDeckModal = false
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async deleteDeck() {
|
||||
if (!this.sel || !confirm(`Delete "${this.sel.name}"?`)) return
|
||||
await fetch('/api/decks/'+this.sel.id, { method: 'DELETE', headers: this.adminHeaders() })
|
||||
this.sel = null; await this.load()
|
||||
},
|
||||
|
||||
// ── Card CRUD ────────────────────────────────────────
|
||||
openNewCard() {
|
||||
this.editCardId = null
|
||||
this.cardForm = { title: '', type: 'action', question: '', a: '', b: '', requires: [] }
|
||||
this.cardErr = null; this.showCardModal = true
|
||||
},
|
||||
openEditCard(card) {
|
||||
this.editCardId = card.id
|
||||
this.cardForm = { title: card.title, type: card.type||'action',
|
||||
question: card.question||'', a: card.a, b: card.b, requires: [...(card.requires||[])] }
|
||||
this.cardErr = null; this.showCardModal = true
|
||||
},
|
||||
toggleReq(val, e) {
|
||||
if (e.target.checked) { if (!this.cardForm.requires.includes(val)) this.cardForm.requires.push(val) }
|
||||
else this.cardForm.requires = this.cardForm.requires.filter(r => r !== val)
|
||||
},
|
||||
async saveCard() {
|
||||
if (!this.cardForm.title || !this.cardForm.a || !this.cardForm.b) {
|
||||
this.cardErr = 'Title, A and B required'; return }
|
||||
this.saving = true
|
||||
try {
|
||||
const url = this.editCardId
|
||||
? `/api/decks/${this.sel.id}/cards/${this.editCardId}`
|
||||
: `/api/decks/${this.sel.id}/cards`
|
||||
const r = await fetch(url, { method: this.editCardId?'PUT':'POST',
|
||||
headers: this.adminHeaders(), body: JSON.stringify(this.cardForm) })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { this.cardErr = d.error; return }
|
||||
await this.load()
|
||||
this.showCardModal = false
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async deleteCard(card) {
|
||||
if (!confirm(`Delete "${card.title}"?`)) return
|
||||
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}`, { method: 'DELETE', headers: this.adminHeaders() })
|
||||
await this.load()
|
||||
},
|
||||
|
||||
// ── Card images ──────────────────────────────────────
|
||||
triggerCardImageUpload(card) {
|
||||
document.getElementById('ci-'+card.id)?.click()
|
||||
},
|
||||
async handleCardImages(card, e) {
|
||||
const files = [...e.target.files]
|
||||
for (const file of files) {
|
||||
const base64 = await this.toBase64(file)
|
||||
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}/images`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ filename: file.name, data: base64, keywords: [] })
|
||||
})
|
||||
}
|
||||
e.target.value = ''
|
||||
await this.load()
|
||||
},
|
||||
async deleteCardImage(card, img) {
|
||||
if (!confirm('Delete image?')) return
|
||||
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}/images/${img.id}`, { method: 'DELETE', headers: this.adminHeaders() })
|
||||
await this.load()
|
||||
},
|
||||
|
||||
// ── Pool images ──────────────────────────────────────
|
||||
handlePoolDrop(e) {
|
||||
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
|
||||
if (files.length) this.uploadPool(files)
|
||||
},
|
||||
handlePoolFiles(e) {
|
||||
const files = [...e.target.files]
|
||||
if (files.length) this.uploadPool(files)
|
||||
e.target.value = ''
|
||||
},
|
||||
async uploadPool(files) {
|
||||
this.uploading = true
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
this.uploadProg = `${i+1}/${files.length}`
|
||||
const base64 = await this.toBase64(files[i])
|
||||
await fetch(`/api/decks/${this.sel.id}/images`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ filename: files[i].name, data: base64, keywords: [] })
|
||||
})
|
||||
}
|
||||
this.uploading = false; await this.load()
|
||||
},
|
||||
async addPoolKw(img, input) {
|
||||
const kw = input.value.trim().toLowerCase().replace(/[,\s]+$/,'')
|
||||
if (!kw || img.keywords.includes(kw)) return
|
||||
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ keywords: [...img.keywords, kw] })
|
||||
})
|
||||
await this.load()
|
||||
},
|
||||
async removePoolKw(img, kw) {
|
||||
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ keywords: img.keywords.filter(k => k !== kw) })
|
||||
})
|
||||
await this.load()
|
||||
},
|
||||
async deletePoolImage(img) {
|
||||
if (!confirm('Delete image?')) return
|
||||
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, { method: 'DELETE', headers: this.adminHeaders() })
|
||||
await this.load()
|
||||
},
|
||||
|
||||
toBase64(file) {
|
||||
return new Promise(res => {
|
||||
const r = new FileReader()
|
||||
r.onload = e => res(e.target.result.split(',')[1])
|
||||
r.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user