chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent dd0970ed7c
commit 4002ff9fbf
156 changed files with 27333 additions and 92 deletions
@@ -0,0 +1 @@
.btn[data-v-7d3f1e61]{justify-content:center;align-items:center;gap:var(--space-2);min-height:var(--touch-min);padding:0 var(--space-5);border-radius:var(--radius-full);font-family:var(--font-family);font-size:var(--text-base);cursor:pointer;transition:opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);white-space:nowrap;border:none;font-weight:600;line-height:1;text-decoration:none;display:inline-flex}.btn[data-v-7d3f1e61]:disabled{opacity:.4;cursor:not-allowed}.btn[data-v-7d3f1e61]:not(:disabled):active{transform:scale(.96)}.btn--primary[data-v-7d3f1e61]{background:var(--color-primary);color:var(--color-primary-fg)}.btn--secondary[data-v-7d3f1e61]{background:var(--color-secondary);color:var(--color-secondary-fg);border:1px solid var(--color-border)}.btn--ghost[data-v-7d3f1e61]{color:var(--color-text);border:1px solid var(--color-border);background:0 0}.btn--destructive[data-v-7d3f1e61]{background:var(--color-destructive);color:var(--color-destructive-fg)}.btn--icon-pill[data-v-7d3f1e61]{width:var(--touch-min);border-radius:var(--radius-full);background:var(--color-surface-2);color:var(--color-text);padding:0}.btn__spinner[data-v-7d3f1e61]{border:2px solid;border-top-color:#0000;border-radius:50%;width:16px;height:16px;animation:.7s linear infinite spin-7d3f1e61}@keyframes spin-7d3f1e61{to{transform:rotate(360deg)}}.sheet-overlay[data-v-81ce2dd1]{z-index:100;background:#0006;align-items:flex-end;display:flex;position:fixed;inset:0}.sheet[data-v-81ce2dd1]{background:var(--color-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;width:100%;padding:var(--space-3) var(--space-4) var(--space-6);outline:none;max-height:90dvh;overflow-y:auto}.sheet__handle[data-v-81ce2dd1]{border-radius:var(--radius-full);background:var(--color-border);width:36px;height:4px;margin:0 auto var(--space-4)}.sheet-enter-active .sheet-overlay[data-v-81ce2dd1]{transition:background var(--duration-base) var(--ease-out)}.sheet-enter-active .sheet[data-v-81ce2dd1]{transition:transform .25s var(--ease-out)}.sheet-leave-active .sheet[data-v-81ce2dd1]{transition:transform .2s ease-in}.sheet-leave-active[data-v-81ce2dd1]{transition:background .2s ease-in}.sheet-enter-from[data-v-81ce2dd1]{background:0 0}.sheet-enter-from .sheet[data-v-81ce2dd1]{transform:translateY(100%)}.sheet-leave-to[data-v-81ce2dd1]{background:0 0}.sheet-leave-to .sheet[data-v-81ce2dd1]{transform:translateY(100%)}
@@ -0,0 +1 @@
import{C as e,H as t,M as n,P as r,R as i,_ as a,d as o,f as s,k as c,p as l,r as u,s as d,t as f,u as p,v as m,w as h,z as g}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{c as _,d as v,f as y}from"./index-6y_HJqaF.js";var b=u(`devices`,()=>{let e=t([]),n=t(!1),r=t(null);async function i(){n.value=!0,r.value=null;try{let t=await fetch(`/api/devices`);if(!t.ok)throw Error(`Failed to load devices`);e.value=await t.json()}catch(e){r.value=e instanceof Error?e.message:`Unknown error`}finally{n.value=!1}}async function a(t,n){let r=await fetch(`/api/devices/${t}`,{method:`PATCH`,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)});if(!r.ok)throw Error(`Failed to update device`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function o(t,n){let r=await fetch(`/api/devices/${t}/lock`,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify({imageId:n})});if(!r.ok)throw Error(`Failed to lock image`);let i=await r.json(),a=e.value.findIndex(e=>e.id===t);return a!==-1&&(e.value[a]=i),i}async function s(t){let n=await fetch(`/api/devices/${t}/lock`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to unlock`);let r=await n.json(),i=e.value.findIndex(e=>e.id===t);return i!==-1&&(e.value[i]=r),r}return{devices:e,loading:n,error:r,fetchDevices:i,updateDevice:a,lockImage:o,unlockImage:s}}),x=u(`upload`,()=>{let e=t(null),n=t(null),r=t(null),i=t(null),a=t(null),o=t([]),s=t(null),c=t([]),l=t(null);function u(t,r){g(),e.value=t,n.value=URL.createObjectURL(t),s.value=r??null,c.value=r?[r]:[]}async function d(t,r){g();let i=await(await fetch(t.originalUrl)).blob();e.value=new File([i],t.originalFilename,{type:i.type}),n.value=URL.createObjectURL(i),l.value=t.id,a.value=t.cropParams??null,o.value=t.stickerState?[...t.stickerState]:[],c.value=t.approvedDeviceIds,s.value=r??null}function f(e,t){i.value&&URL.revokeObjectURL(i.value),r.value=e,i.value=URL.createObjectURL(e),a.value=t}function p(e){o.value=[...o.value,e]}function m(e,t){o.value=o.value.map(n=>n.id===e?{...n,...t}:n)}function h(e){o.value=o.value.filter(t=>t.id!==e)}function g(){n.value&&URL.revokeObjectURL(n.value),i.value&&URL.revokeObjectURL(i.value),e.value=null,n.value=null,r.value=null,i.value=null,a.value=null,o.value=[],s.value=null,c.value=[],l.value=null}return{originalFile:e,originalUrl:n,croppedBlob:r,croppedUrl:i,cropParams:a,stickers:o,contextDeviceId:s,selectedDeviceIds:c,editingImageId:l,init:u,initEdit:d,setCrop:f,addSticker:p,updateSticker:m,removeSticker:h,cleanup:g}}),S={key:0,class:`btn__spinner`,"aria-hidden":`true`},C=f(m({__name:`BaseButton`,props:{variant:{default:`primary`},tag:{default:`button`},type:{default:`button`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1}},setup(t){return(i,a)=>(c(),o(r(t.tag),e({type:t.tag===`button`?t.type:void 0,disabled:t.disabled||t.loading,class:[`btn`,`btn--${t.variant}`,{"btn--loading":t.loading}]},i.$attrs),{default:g(()=>[t.loading?(c(),l(`span`,S)):s(``,!0),n(i.$slots,`default`,{},void 0,!0)]),_:3},16,[`type`,`disabled`,`class`]))}}),[[`__scopeId`,`data-v-7d3f1e61`]]),w=[`aria-label`],T=f(m({__name:`BaseBottomSheet`,props:{modelValue:{type:Boolean},label:{}},emits:[`update:modelValue`],setup(e,{emit:r}){let u=e,f=r,m=t(null),b=null;function x(){f(`update:modelValue`,!1)}return i(()=>u.modelValue,async e=>{e?(b=document.activeElement,await h(),m.value?.focus()):(b?.focus(),b=null)}),(t,r)=>(c(),o(d,{to:`body`},[a(_,{name:`sheet`},{default:g(()=>[e.modelValue?(c(),l(`div`,{key:0,class:`sheet-overlay`,role:`dialog`,"aria-label":e.label,"aria-modal":`true`,onClick:y(x,[`self`]),onKeydown:v(x,[`esc`])},[p(`div`,{ref_key:`sheetRef`,ref:m,class:`sheet`,tabindex:`-1`},[r[0]||=p(`div`,{class:`sheet__handle`,"aria-hidden":`true`},null,-1),n(t.$slots,`default`,{},void 0,!0)],512)],40,w)):s(``,!0)]),_:3})]))}}),[[`__scopeId`,`data-v-81ce2dd1`]]);export{b as i,C as n,x as r,T as t};
@@ -0,0 +1 @@
.device-picker__title[data-v-a6466fa5]{font-size:var(--text-md);margin-bottom:var(--space-2);font-weight:700}.device-picker__sub[data-v-a6466fa5]{font-size:var(--text-sm);color:var(--color-text-muted);margin-bottom:var(--space-4)}.device-picker__list[data-v-a6466fa5]{gap:var(--space-1);margin-bottom:var(--space-5);flex-direction:column;display:flex}.device-picker__row[data-v-a6466fa5]{align-items:center;gap:var(--space-3);padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);cursor:pointer;min-height:var(--touch-min);display:flex}.device-picker__check[data-v-a6466fa5]{width:20px;height:20px;accent-color:var(--color-primary);cursor:pointer;flex-shrink:0}.device-picker__name[data-v-a6466fa5]{font-size:var(--text-base);flex:1;font-weight:600}.device-picker__orientation[data-v-a6466fa5]{font-size:var(--text-xs);color:var(--color-text-muted);text-transform:capitalize}.device-picker__confirm[data-v-a6466fa5]{width:100%}
@@ -0,0 +1 @@
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-YbQyMMAQ.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{E as e,c as t,d as n,t as r}from"./_plugin-vue_export-helper-CnSQ-FNj.js";var i={},a={class:`view`};function o(r,i){return e(),n(`main`,a,[...i[0]||=[t(`h1`,null,`Library`,-1)]])}var s=r(i,[[`render`,o],[`__scopeId`,`data-v-afbdd666`]]);export{s as default};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.view[data-v-afbdd666]{padding:var(--space-4)}
@@ -1 +0,0 @@
import{E as e,O as t,Y as n,c as r,d as i,dt as a,g as o,i as s,lt as c,s as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CnSQ-FNj.js";import{n as p,r as m,t as h}from"./index-KHHWwfaX.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(o({__name:`SettingsView`,setup(o){let u=m(),{saveTheme:T}=p(),E=l(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(o,l)=>(e(),i(`main`,g,[l[5]||=r(`h1`,{class:`settings__title`},`Settings`,-1),r(`section`,_,[l[1]||=r(`h2`,{class:`settings__section-title`},`Theme`,-1),r(`div`,v,[(e(!0),i(s,null,t(n(h),t=>(e(),i(`button`,{key:t.id,type:`button`,role:`radio`,"aria-checked":E.value===t.id,"aria-label":t.label,class:c([`theme-swatch`,{"theme-swatch--active":E.value===t.id}]),style:f({"--swatch-bg":t.bg,"--swatch-primary":t.primary,"--swatch-text":t.text}),onClick:e=>D(t.id)},[l[0]||=r(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[r(`span`,{class:`theme-swatch__bar`}),r(`span`,{class:`theme-swatch__dot`})],-1),r(`span`,b,a(t.label),1),E.value===t.id?(e(),i(`span`,x,``)):d(``,!0)],14,y))),128))])]),r(`section`,S,[l[3]||=r(`h2`,{class:`settings__section-title`},`Account`,-1),r(`div`,C,[l[2]||=r(`span`,{class:`settings__row-label`},`Signed in as`,-1),r(`span`,w,a(n(u).user?.email),1)]),l[4]||=r(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-1446e085`]]);export{T as default};
@@ -0,0 +1 @@
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-6y_HJqaF.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,``)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-1446e085`]]);export{T as default};
@@ -1 +0,0 @@
import{E as e,c as t,d as n,t as r}from"./_plugin-vue_export-helper-CnSQ-FNj.js";var i={},a={class:`view`};function o(r,i){return e(),n(`main`,a,[...i[0]||=[t(`h1`,null,`Shared`,-1)]])}var s=r(i,[[`render`,o],[`__scopeId`,`data-v-4046603a`]]);export{s as default};
@@ -1 +0,0 @@
.view[data-v-4046603a]{padding:var(--space-4)}
@@ -0,0 +1 @@
.crop-editor[data-v-ec2eb68c]{touch-action:none;background:#000;flex-direction:column;flex:1;min-height:0;display:flex;position:relative}.crop-editor__canvas[data-v-ec2eb68c]{touch-action:none;cursor:grab;flex:1;min-height:0;display:block}.crop-editor__canvas[data-v-ec2eb68c]:active{cursor:grabbing}.crop-editor__label[data-v-ec2eb68c]{color:#fff;font-size:var(--text-xs);letter-spacing:.04em;pointer-events:none;background:#0009;border-radius:999px;padding:4px 12px;font-weight:700;position:absolute;top:16px;left:50%;transform:translate(-50%)}.crop-editor__actions[data-v-ec2eb68c]{padding:var(--space-4);justify-content:center;display:flex;position:absolute;bottom:0;left:0;right:0}.crop-editor__use-btn[data-v-ec2eb68c]{width:100%;max-width:320px}.sticker-tray__cats[data-v-7eada75b]{gap:var(--space-2);padding-bottom:var(--space-3);scrollbar-width:none;display:flex;overflow-x:auto}.sticker-tray__cats[data-v-7eada75b]::-webkit-scrollbar{display:none}.sticker-tray__cat[data-v-7eada75b]{border:1.5px solid var(--color-border);font-size:var(--text-sm);white-space:nowrap;cursor:pointer;color:var(--color-text-muted);transition:all var(--duration-fast);background:0 0;border-radius:999px;padding:6px 14px;font-weight:600}.sticker-tray__cat--active[data-v-7eada75b]{background:var(--color-primary);border-color:var(--color-primary);color:var(--color-primary-fg)}.sticker-tray__grid[data-v-7eada75b]{gap:var(--space-2);grid-template-columns:repeat(5,1fr);display:grid}.sticker-tray__item[data-v-7eada75b]{padding:var(--space-2) var(--space-1);border-radius:var(--radius-sm);cursor:pointer;transition:background var(--duration-fast);background:0 0;border:none;flex-direction:column;align-items:center;gap:4px;display:flex}.sticker-tray__item[data-v-7eada75b]:active{background:var(--color-surface-2)}.sticker-tray__emoji[data-v-7eada75b]{font-family:Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:36px;line-height:1}.sticker-tray__label[data-v-7eada75b]{color:var(--color-text-muted);text-align:center;font-size:10px;font-weight:600;line-height:1.2}.sticker-canvas[data-v-fb52db70]{background:#111;flex-direction:column;flex:1;align-items:center;min-height:0;display:flex;position:relative;overflow:hidden}.sticker-canvas[data-v-fb52db70] .konvajs-content{flex-shrink:0}.sticker-canvas__delete[data-v-fb52db70]{top:var(--space-3);right:var(--space-3);cursor:pointer;color:#fff;z-index:10;background:#c81e1ed9;border:none;border-radius:50%;justify-content:center;align-items:center;width:36px;height:36px;display:flex;position:absolute}.sticker-canvas__bar[data-v-fb52db70]{align-items:center;gap:var(--space-3);height:72px;padding:0 var(--space-4);background:var(--color-surface);border-top:1px solid var(--color-border);display:flex;position:absolute;bottom:0;left:0;right:0}.sticker-canvas__add-btn[data-v-fb52db70]{align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);border:1.5px solid var(--color-border);color:var(--color-text);font-size:var(--text-sm);cursor:pointer;white-space:nowrap;background:0 0;font-weight:600;display:flex}.sticker-canvas__next-btn[data-v-fb52db70]{min-width:96px;margin-left:auto}.upload-view[data-v-fca2e263]{z-index:100;background:var(--color-bg);flex-direction:column;display:flex;position:fixed;inset:0}.upload-view__header[data-v-fca2e263]{height:56px;padding:0 var(--space-4);background:var(--color-surface);border-bottom:1px solid var(--color-border);flex-shrink:0;align-items:center;display:flex;position:relative}.upload-view__back[data-v-fca2e263]{cursor:pointer;width:40px;height:40px;color:var(--color-text);background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;margin-left:-8px;display:flex}.upload-view__step-label[data-v-fca2e263]{font-size:var(--text-base);color:var(--color-text);font-weight:700;position:absolute;left:50%;transform:translate(-50%)}.upload-view__skip[data-v-fca2e263]{font-size:var(--text-sm);color:var(--color-text-muted);cursor:pointer;padding:var(--space-2) 0;background:0 0;border:none;margin-left:auto;font-weight:600}.upload-view__stage[data-v-fca2e263]{flex:1;min-height:0}.upload-view__done[data-v-fca2e263]{justify-content:center;align-items:center;gap:var(--space-4);padding:var(--space-6) var(--space-5);text-align:center;flex-direction:column;flex:1;display:flex}.upload-view__done-icon[data-v-fca2e263]{font-family:Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif;font-size:64px;line-height:1}.upload-view__done-title[data-v-fca2e263]{font-size:var(--text-xl);font-weight:700}.upload-view__done-sub[data-v-fca2e263]{font-size:var(--text-sm);color:var(--color-text-muted);max-width:260px;line-height:1.5}.upload-view__done-btn[data-v-fca2e263]{width:100%;max-width:320px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long