Compare commits

..
2 Commits
24 changed files with 350 additions and 21 deletions
+2
View File
@@ -3,6 +3,8 @@ TZ=Asia/Shanghai
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
WEB_BIND=127.0.0.1
WEB_PORT=6005
# Display name of the NAS/server storing the local gallery.
FRAME_FLOW_STORAGE_NAME=NAS
KOKONNA_MOCK=true
KOKONNA_WRITE_ENABLED=false
KOKONNA_API_KEY=
+3
View File
@@ -1,6 +1,8 @@
name: Verify Frame Flow
on:
push:
# Branch verification gates releases; tags do not duplicate the same build.
branches: ["**"]
pull_request:
workflow_dispatch:
inputs:
@@ -65,6 +67,7 @@ jobs:
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
FRAME_FLOW_STORAGE_NAME: ${{ vars.FRAME_FLOW_STORAGE_NAME }}
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
PROD_MOCK: ${{ vars.PROD_MOCK }}
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
+1
View File
@@ -23,6 +23,7 @@ services:
TZ: Asia/Shanghai
DATA_DIR: /data
CONFIG_DIR: /config
FRAME_FLOW_STORAGE_NAME: ${FRAME_FLOW_STORAGE_NAME:-NAS}
volumes:
- ./data:/data
- ./config:/config
+4
View File
@@ -43,6 +43,10 @@ WEB_PORT=6005. The latter binds every NAS interface, so use the LAN IP when
strict LAN-only binding is required. No firewall/router/proxy edits are performed.
Copy deploy/devices.yml.example to config/devices.yml if multiple devices are desired.
Set `FRAME_FLOW_STORAGE_NAME` to the NAS display name. The gallery tab shows
this name followed by “本地图库” (or “Local gallery”). For Actions deployment,
set the repository Variable of the same name. It is a display label, not a path.
## 3. Validate, build, start / 验证构建启动
Synology Docker is /usr/local/bin/docker. The SSH user may require sudo. Do not
+19
View File
@@ -23,3 +23,22 @@ The UI labels the top card **Shared connection**, displays source IDs and paths
on each source card, and explains each source's connection dependency. Shared
session disconnect is available only in the shared connection card and asks for
confirmation of its impact on all people-filtered sources.
## Gallery destination labels / 图库目的地标签
图库标签描述可保存图片的具体目的地,照片来源连接不自动成为图库目的地。
当前为 `KoKonna 云端图库` 与 `<FRAME_FLOW_STORAGE_NAME> 本地图库`;
NAS 名称通过部署配置设置,空值回退为 NAS,不使用容器 ID 代替 NAS 名称。
英文使用同一实例名称与 cloud gallery / Local gallery。
`GET /api/v1/settings` 的 `gallery_destinations` 提供目的地 ID、适配器、
实例/服务商名称、存储类型、连接状态和保存能力。只有连接有效、明确支持上传且
前端存在对应适配器的目的地才进入标签栏。能力默认关闭;全局写入保护仍单独生效,
目的地的保存能力不等于当前用户获准写入。
后续网盘接入须先实现其读取/保存适配器、校验授权及能力,再注册目的地。
命名规则为“服务商/实例名称 + 云端图库”,例如已实现保存能力后才可展示
“百度网盘·家庭 云端图库”。多个账号/实例必须能从名称区分。
目前 Google Drive、阿里云盘、百度网盘和 WebDAV 仅作为只读照片来源,
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
+14 -1
View File
@@ -39,7 +39,7 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
| --- | --- | --- |
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;真实图片数量、搜索先于图片请求、无骨架 | 14 项通过 |
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
@@ -47,3 +47,16 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
## 首次未缓存图库的初始化
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
+1
View File
@@ -37,6 +37,7 @@ Repository Settings → Actions configuration (private paths/addresses use Secre
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
| `PROD_WEB_PORT` | Exact existing mapped port |
| `FRAME_FLOW_STORAGE_NAME` | Optional NAS display name for the local gallery; defaults to NAS |
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
+82 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Pigway Frame Flow API",
"version": "0.1.5"
"version": "0.1.7"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -14819,6 +14819,24 @@
],
"title": "Device"
},
"DeviceIdentity": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
}
},
"type": "object",
"required": [
"id",
"name"
],
"title": "DeviceIdentity"
},
"DisplayGeometry": {
"properties": {
"width": {
@@ -15047,6 +15065,48 @@
],
"title": "FutureContent"
},
"GalleryDestination": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"adapter": {
"type": "string",
"title": "Adapter"
},
"name": {
"type": "string",
"title": "Name"
},
"storage": {
"type": "string",
"enum": [
"cloud",
"local"
],
"title": "Storage"
},
"connected": {
"type": "boolean",
"title": "Connected",
"default": false
},
"supports_upload": {
"type": "boolean",
"title": "Supports Upload",
"default": false
}
},
"type": "object",
"required": [
"id",
"adapter",
"name",
"storage"
],
"title": "GalleryDestination"
},
"GalleryImage": {
"properties": {
"starred": {
@@ -15156,7 +15216,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.5"
"default": "0.1.7"
}
},
"type": "object",
@@ -17091,6 +17151,25 @@
},
"SettingsView": {
"properties": {
"storage_name": {
"type": "string",
"title": "Storage Name",
"default": "NAS"
},
"gallery_destinations": {
"items": {
"$ref": "#/components/schemas/GalleryDestination"
},
"type": "array",
"title": "Gallery Destinations"
},
"device_catalog": {
"items": {
"$ref": "#/components/schemas/DeviceIdentity"
},
"type": "array",
"title": "Device Catalog"
},
"version": {
"type": "string",
"title": "Version"
@@ -17333,7 +17412,7 @@
"version": {
"type": "string",
"title": "Version",
"default": "0.1.5"
"default": "0.1.7"
},
"api_version": {
"type": "string",
+29
View File
@@ -0,0 +1,29 @@
# Pigway Frame Flow v0.1.6
## 简体中文
- 图库使用具体目的地标签:`KoKonna 云端图库` 与 `<NAS 名称> 本地图库`,NAS 名称通过 `FRAME_FLOW_STORAGE_NAME` 配置。
- Settings API 增加目的地元数据,标签仅展示连接已配置、明确支持保存且存在适配器的目的地;全局写入保护保持独立。
- 后续网盘须实现读取/保存适配器并完成连接后才能加入标签栏,显示具体服务商/实例名称。当前只读照片来源不会作为保存目的地展示。
- 长标签在手机上换行,避免整页横向溢出;中英文保持相同目的地身份。
- Compose 和自动部署透传 NAS 展示名称;分支 CI 完整验证后发布标签,避免相同提交的标签推送重复占用构建队列。
### 验证
本地 507 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法检查通过。
图库目的地标签与能力过滤 18 项浏览器检查通过;覆盖 320–1440 像素、中英文、明暗主题和长 NAS 名称。
本次不增加任何网盘写入功能,不改变画框写入策略。
### 升级
Compose 设置 `FRAME_FLOW_STORAGE_NAME`;Actions 设置同名仓库 Variable。
名称只描述实例,不作为路径或授权凭据。未设置时回退为 NAS。
默认访问端口仍为 6005,容器内部端口仍为 18787。
NAS 源码包与 SHA256 附件不含运行配置、密钥、数据库或私人照片,需自行构建镜像。
## English
Gallery tabs identify the concrete destination: KoKonna cloud gallery and the configured NAS name plus Local gallery.
Destination metadata includes connection and upload capabilities; only implemented writable adapters are shown.
Existing read-only cloud photo sources are not presented as save destinations. Long labels wrap on mobile.
Set FRAME_FLOW_STORAGE_NAME in Compose or the Actions repository Variables. No cloud-drive write support or device write policy is changed.
+24
View File
@@ -0,0 +1,24 @@
# Pigway Frame Flow v0.1.7
## 简体中文
修复首次打开图库时内容区空白、必须等待云端图片列表返回才出现控件的问题。
- 目的地标签、搜索、排序、刷新和上传按钮立即初始化,图片列表独立请求。
- 仅在工具栏提示正在读取列表;未知数量时不生成图片卡片,不提前显示“图库为空”。实际图片仍在自己的区域内独立加载。
- 启动使用轻量设备身份目录,完整设备状态在后台更新,避免厂商状态请求阻塞首屏。
- 慢请求期间可以切换到 NAS 图库;过期响应不会覆盖当前页面。
- 列表失败时保留控件并在工具栏显示错误,仍可刷新或切换目的地。
- 保留等待期间输入的搜索词和排序选择;本地任务筛选请求在同一次导航内复用。
### 验证
本地 508 项 Python 测试、Ruff、格式检查、mypy 和 JavaScript 语法检查通过。
新增首次未缓存列表的控件就绪、输入保留、慢云端切换和列表失败回归。
不增加图库列表的服务端缓存,不修改厂商请求重试、照片、生成内容或设备写入策略。
## English
Fix the blank gallery on its first uncached visit. Destination tabs and gallery controls initialize before metadata requests complete.
Slow or failed cloud listing does not prevent switching to NAS storage. Search and sort choices survive list arrival, and stale responses cannot replace the active destination.
Only a small toolbar status indicates metadata loading; real images keep independent loading indicators.
+33 -2
View File
@@ -33,9 +33,11 @@ from pigway_frame_flow.models import (
CurrentDisplay,
DeleteRequest,
Device,
DeviceIdentity,
DisplayRequest,
ErrorResponse,
Fit,
GalleryDestination,
GalleryImage,
Health,
History,
@@ -124,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
version="0.1.5",
version="0.1.7",
lifespan=lifespan,
responses=error_responses,
)
@@ -1000,7 +1002,36 @@ def create_app(settings=None, start_worker=True):
)
def settings_view():
return SettingsView(
version="0.1.5",
storage_name=settings.storage_name,
# Page navigation needs device identity, not a fresh cloud status request.
device_catalog=[
DeviceIdentity(
id=id,
name=getattr(getattr(driver, "cached", None), "name", id),
)
for id, driver in registry.drivers.items()
],
gallery_destinations=[
GalleryDestination(
id="cloud",
adapter="kokonna",
name="KoKonna",
storage="cloud",
supports_upload=True,
connected=settings.mock
or bool(settings.api_key)
or any(registry.keys),
),
GalleryDestination(
id="local",
adapter="local-assets",
name=settings.storage_name,
storage="local",
connected=True,
supports_upload=True,
),
],
version="0.1.7",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
+19 -2
View File
@@ -333,7 +333,24 @@ class History(BaseModel):
timestamp: str
class GalleryDestination(BaseModel):
id: str
adapter: str
name: str
storage: Literal["cloud", "local"]
connected: bool = False
supports_upload: bool = False
class DeviceIdentity(BaseModel):
id: str
name: str
class SettingsView(BaseModel):
storage_name: str = "NAS"
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
device_catalog: list[DeviceIdentity] = Field(default_factory=list)
version: str
mock: bool
write_enabled: bool
@@ -346,7 +363,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
version: str = "0.1.5"
version: str = "0.1.7"
class Readiness(BaseModel):
@@ -376,7 +393,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
version: str = "0.1.5"
version: str = "0.1.7"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
+5
View File
@@ -27,6 +27,11 @@ class Settings:
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
)
additional_secrets: list[str] = field(default_factory=list, repr=False)
storage_name: str = field(
default_factory=lambda: (
os.getenv("FRAME_FLOW_STORAGE_NAME", "NAS").strip() or "NAS"
)
)
device_id: str = "living-room"
timezone: str = "Asia/Shanghai"
cors_origins: list[str] = field(
+26 -5
View File
@@ -1,8 +1,13 @@
'use strict';
const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){const [settings,devices,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/devices').catch(error=>{toast(error.message);return [];}),api('/api/v1/templates')]);state.settings=settings;state.devices=devices;state.templates=templates;state.device=state.devices[0]?.id||state.device;}
function forgetLogin(){state.galleryQuery='';state.galleryOrder='newest';state.galleryJobsRequest=null;state.galleryCache?.clear();localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){
const [settings,templates]=await Promise.all([api('/api/v1/settings'),api('/api/v1/templates')]);
const devices=settings.device_catalog||await api('/api/v1/devices').catch(error=>{toast(error.message);return [];});
state.settings=settings;state.devices=devices;state.templates=templates;state.device=devices[0]?.id||settings.device_id||state.device;
if(settings.device_catalog&&state.page!=='dashboard')void api('/api/v1/devices').then(details=>{if(state.settings!==settings||!state.token)return;state.devices=details;shell();}).catch(error=>{if(state.settings===settings&&state.token)toast(error.message);});
}
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -30,6 +35,15 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
window.addEventListener('resize',revealCurrentNavigation);
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(d.name)}</option>`).join('');revealCurrentNavigation();}
// A destination appears only when its connection and read/write adapter are available.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
function galleryDestinations(){
const configured=state.settings?.gallery_destinations;
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
}
function galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
function imageLoading(img){
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
const box=document.createElement('div');box.className='image-loading';
@@ -102,15 +116,22 @@ const views={
studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(),
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async gallery(epoch){
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
const render=items=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
if(cached)render(cached);
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
render(cached||[],!cached);
try{
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
if(epoch!==state.epoch)return;cache.set(key,items);
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(items);
}catch(error){
if(epoch!==state.epoch)return;if(!state.token)throw error;
const status=$('#gallery-list-status');status.hidden=false;status.setAttribute('role','alert');status.textContent=error.message;
$('.gallery')?.setAttribute('aria-busy','false');
}
},
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
+3 -2
View File
@@ -34,9 +34,10 @@ function displayDialog(content){
function enhanceGallery(){
const grid=$('.gallery');if(!grid)return;
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';void api('/api/v1/jobs').then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
if(state.tab==='local'){const bar=document.createElement('div');bar.className='actions';bar.innerHTML=`<select id="asset-origin" aria-label="${t('originFilter')}">${['all','manual','task','external','template','unknown'].map(x=>`<option value="${x}">${t('origin_'+x)}</option>`).join('')}</select><select id="asset-job" aria-label="${t('taskOrigin')}"><option value="">${t('allTasks')}</option></select><button id="asset-prev">${t('previousPage')}</button><button id="asset-next">${t('nextPage')}</button><button id="asset-all">${t('selectAll')}</button><button id="asset-batch-star">${t('star')}</button><button id="asset-batch-unstar">${t('unstar')}</button><button id="asset-batch-delete" class="danger">${t('deleteSelected')}</button>`;grid.before(bar);$('#asset-origin').value=state.assetOrigin||'all';if(state.galleryJobsEpoch!==state.epoch){state.galleryJobsEpoch=state.epoch;state.galleryJobsRequest=api('/api/v1/jobs');}void state.galleryJobsRequest.then(jobs=>{if(!bar.isConnected)return;const picker=bar.querySelector('#asset-job');for(const job of jobs){const option=document.createElement('option');option.value=job.id;option.textContent=job.name;picker.append(option);}picker.value=state.assetJob||'';}).catch(error=>{if(bar.isConnected)toast(error.message);});$('#asset-origin').onchange=()=>{state.assetOffset=0;state.assetOrigin=$('#asset-origin').value;return navigate();};$('#asset-job').onchange=()=>{state.assetOffset=0;state.assetJob=$('#asset-job').value;state.assetOrigin=state.assetJob?'task':state.assetOrigin;return navigate();};$('#asset-prev').disabled=!(state.assetOffset||0);$('#asset-next').disabled=state.gallery.length<200;bind('asset-prev',()=>{state.assetOffset=Math.max(0,(state.assetOffset||0)-200);return navigate();});bind('asset-next',()=>{state.assetOffset=(state.assetOffset||0)+200;return navigate();});$('#asset-all').onclick=()=>{const boxes=[...document.querySelectorAll('.asset-select')].filter(x=>!x.closest('.image-card').hidden);const checked=boxes.some(x=>!x.checked);boxes.forEach(x=>x.checked=checked);};for(const action of ['star','unstar','delete']){bind('asset-batch-'+action,async()=>{const ids=[...document.querySelectorAll('.asset-select:checked')].map(x=>x.dataset.asset);if(!ids.length){toast(t('selectImagesFirst'));return;}if(action==='delete'&&!confirm(t('deleteSelectedConfirm')))return;const results=[];for(let i=0;i<ids.length;i+=100){const r=await api('/api/v1/assets/batch',{method:'POST',body:{asset_ids:ids.slice(i,i+100),action}});results.push(...r.results);}const failed=results.filter(x=>x.status==='failed');if(failed.length)toast(t('batchFailed')+' '+failed.length);await navigate();});}}
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{state.galleryQuery=$('#gallery-search').value;const query=state.galleryQuery.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;state.galleryOrder=order;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
cards.forEach((card,i)=>{const item=state.gallery[i];if(state.tab==='local'){const star=document.createElement('button');star.textContent=t(item.starred?'unstar':'star');star.setAttribute('aria-pressed',String(item.starred));card.querySelector('.actions').append(star);star.onclick=busy(async()=>{await api('/api/v1/assets/'+item.asset_id+'/star',{method:'PUT',body:{starred:!item.starred}});await navigate();});const label=document.createElement('small');label.textContent=item.origin==='task'?t('taskOrigin')+' · '+(item.job_name||item.job_id):t('origin_'+item.origin);card.querySelector('h3').after(label);const select=document.createElement('input');select.type='checkbox';select.className='asset-select';select.dataset.asset=item.asset_id;select.setAttribute('aria-label',t('selectImage'));card.prepend(select);}if(item.width&&item.height){const dimensions=document.createElement('small');dimensions.textContent=item.width+' × '+item.height;card.querySelector('h3').after(dimensions);}const download=document.createElement('button');download.textContent=t('download');card.querySelector('.actions').append(download);download.onclick=busy(()=>downloadImage(item.url,item.name));if(state.tab==='local'){const edit=document.createElement('button');edit.textContent=t('continueEdit');card.querySelector('.actions').append(edit);edit.onclick=busy(async()=>{const meta=await api('/api/v1/assets/'+item.asset_id+'/source');if(meta.source?.type==='template')await editTemplateSource(meta.source);else toast(t('sourceUnknown'));});}});
$('#gallery-search').value=state.galleryQuery||'';$('#gallery-sort').value=state.galleryOrder||'newest';$('#gallery-search').oninput();$('#gallery-sort').onchange();
}
async function weatherSettings(){
const weather=await api('/api/v1/providers/weather');const box=document.createElement('section');box.className='card';box.innerHTML=`<h3>${t('weatherSettings')}</h3><form id="weather-form"><div class="field"><label for="weather-provider">Provider</label><select id="weather-provider"><option value="mock">${t('weatherMock')}</option><option value="open-meteo">Open-Meteo</option></select></div><div class="field"><label for="weather-location">${t('location')}</label><input id="weather-location" required maxlength="120" value="${esc(weather.config.location)}"></div><div class="form-grid">${['latitude','longitude'].map(k=>`<div class="field"><label for="weather-${k}">${t(k)}</label><input id="weather-${k}" required type="number" step="any" min="${k==='latitude'?-90:-180}" max="${k==='latitude'?90:180}" value="${weather.config[k]}"></div>`).join('')}</div><button type="submit" class="primary">${t('save')}</button></form><div id="weather-result"></div>`;$('#main').append(box);$('#weather-provider').value=weather.config.provider;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.5</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.7</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
+5
View File
@@ -146,3 +146,8 @@ form>.modal-actions{margin-top:8px}
.studio-region{position:absolute;border:2px solid var(--accent);cursor:move;touch-action:none;overflow:hidden}
.studio-canvas .studio-region img{position:static;display:block;width:100%;height:100%;object-fit:cover;border:0;pointer-events:none}
.studio-region>.image-loading{height:100%;min-height:0;border-radius:0}
.gallery-tabs{max-width:100%;flex-wrap:wrap}
.gallery-tabs button{min-width:0;white-space:normal;overflow-wrap:anywhere}
.gallery:empty{display:none}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pigway-frame-flow"
version = "0.1.5"
version = "0.1.7"
requires-python = ">=3.12"
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
[project.optional-dependencies]
+5 -1
View File
@@ -403,7 +403,11 @@ class Deploy:
service["image"] = latest
proposed = self.secret_config()
service["environment"].update(proposed)
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
for key in (
"SYNOLOGY_PHOTOS_URL",
"SYNOLOGY_PHOTOS_CERT_SHA256",
"FRAME_FLOW_STORAGE_NAME",
):
if self.env.get(key):
service["environment"][key] = self.env[key]
service["environment"].update(
+10
View File
@@ -0,0 +1,10 @@
/* Mock-only: slow device status and cloud metadata must not block real gallery controls. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);const b=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{const p=await b.newPage({extraHTTPHeaders:{Authorization:'Bearer '+token}}),errors=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/auth/me',r=>r.fulfill({json:{username:'fixture',csrf_token:''}}));let releaseStatus,releaseList;const pending=[];let checks=0;
await p.route('**/api/v1/devices',async r=>{pending.push('device');await new Promise(resolve=>releaseStatus=resolve);await r.fulfill({json:[{id:'living-room',name:'Fixture frame'}]});});
await p.route('**/api/v1/devices/*/images',async r=>{pending.push('list');await new Promise(resolve=>releaseList=resolve);await r.fulfill({json:[]});});
await p.goto(base+'/#gallery');await p.locator('#gallery-search').waitFor({timeout:5000});for(let i=0;i<100&&pending.length<2;i++)await p.waitForTimeout(20);assert.deepEqual(pending.sort(),['device','list']);assert(await p.locator('#gallery-upload').isEnabled());assert(await p.locator('#local-tab').isEnabled());assert.equal(await p.locator('.image-card,.placeholder-line,.image-pending,.page-loading').count(),0);checks++;if(process.env.FRAME_FLOW_SCREENSHOT_DIR){fs.mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/gallery-list-pending.png'});}
await p.locator('#gallery-search').fill('kept query');await p.locator('#gallery-sort').selectOption('name');releaseStatus();releaseList();await p.waitForFunction(()=>document.querySelector('.gallery')?.getAttribute('aria-busy')==='false');assert.equal(await p.locator('#gallery-search').inputValue(),'kept query');assert.equal(await p.locator('#gallery-sort').inputValue(),'name');assert.equal(await p.locator('.card.empty').count(),1);checks++;
assert.deepEqual(errors,[]);console.log('Cold gallery bootstrap:',checks,'checks PASS; device/cloud requests blocked independently, real controls ready, input preserved');
}finally{await b.close();}})().catch(e=>{console.error(e.message);process.exit(1)});
+18
View File
@@ -0,0 +1,18 @@
/* Mock-only: real writable destinations, concrete names and responsive labels. */
const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright');
(async()=>{
const base=process.env.FRAME_FLOW_URL;assert(base);const token=fs.readFileSync(process.env.FRAME_FLOW_ENV_FILE,'utf8').split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice(17);
assert((await(await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}})).json()).mock===true);
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_EXECUTABLE||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();
await page.evaluate(()=>{state.settings.storage_name='FixtureNAS';state.settings.gallery_destinations=[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:'FixtureNAS',storage:'local',connected:true,supports_upload:true},{id:'read-only-drive',adapter:'kokonna',name:'Read-only fixture',storage:'cloud',connected:true,supports_upload:false},{id:'offline-drive',adapter:'kokonna',name:'Offline fixture',storage:'cloud',connected:false,supports_upload:true},{id:'unimplemented-drive',adapter:'unimplemented',name:'Future fixture',storage:'cloud',connected:true,supports_upload:true}];});let checks=0;
for(const width of [320,390,768,1440])for(const lang of ['zh','en'])for(const theme of ['light','dark']){
await page.setViewportSize({width,height:900});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;return navigate('gallery');},{lang,theme});
assert.equal(await page.locator('#cloud-tab').innerText(),lang==='zh'?'KoKonna 云端图库':'KoKonna cloud gallery');assert.equal(await page.locator('#local-tab').innerText(),lang==='zh'?'FixtureNAS 本地图库':'FixtureNAS Local gallery');assert.equal(await page.locator('[data-gallery-destination]').count(),2);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
}
await page.setViewportSize({width:320,height:740});await page.evaluate(()=>{state.settings.gallery_destinations.find(d=>d.id==='local').name='家庭照片存储服务器名称很长的NAS';return navigate('gallery');});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));checks++;
await page.locator('#local-tab').click();assert.equal(await page.evaluate(()=>state.tab),'local');await page.locator('#cloud-tab').click();assert.equal(await page.evaluate(()=>state.tab),'cloud');checks++;
assert.deepEqual(errors,[]);console.log('Gallery destinations:',checks,'checks PASS; concrete labels, capability/connection/adapter gates, responsive layout and tab switching');
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exit(1)});
+11 -1
View File
@@ -18,8 +18,9 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright')
const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve);
await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata;
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.image-card').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
assert(await page.locator('#cloud-tab').isVisible());assert(await page.locator('#local-tab').isEnabled());assert(await page.locator('#gallery-upload').isEnabled());assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#gallery-sort').isEnabled());assert(await page.locator('#gallery-list-status').isVisible());assert.equal(await page.locator('.card.empty').count(),0);await page.locator('#gallery-search').fill('Progressive');await page.locator('#gallery-sort').selectOption('name');checks++;
releaseMetadata();await image;await page.evaluate(()=>window.navigation);
assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++;
assert.equal(await page.locator('#gallery-search').inputValue(),'Progressive');assert.equal(await page.locator('#gallery-sort').inputValue(),'name');assert(await page.locator('#gallery-upload').isEnabled());assert.equal(await page.locator('.image-loading .spinner').count(),1);assert.equal(await page.locator('.image-pending').count(),0);checks++;
assert(await page.locator('.image-loading').evaluate(box=>{const a=box.getBoundingClientRect(),b=box.querySelector('.spinner').getBoundingClientRect();return Math.abs(a.x+a.width/2-b.x-b.width/2)<1&&Math.abs(a.y+a.height/2-b.y-b.height/2)<1;}));checks++;
releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++;
await page.evaluate(()=>navigate('upload'));
@@ -34,6 +35,15 @@ const assert=require('assert'),fs=require('fs'),{chromium}=require('playwright')
assert.equal(await page.locator('.image-pending,.placeholder-line,.page-placeholder').count(),0);assert.equal(await page.locator('.page-loading').count(),0);checks++;
releaseDevice();await page.evaluate(()=>window.navigation);await page.unroute('**/api/v1/devices/living-room');
}
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
let releaseSlow,slowRequested;const slow=new Promise(resolve=>slowRequested=resolve);
await page.route('**/api/v1/devices/*/images',async r=>{slowRequested();await new Promise(resolve=>releaseSlow=resolve);await r.fulfill({json:[]});});
await page.evaluate(()=>{window.slowNavigation=navigate('gallery');});await slow;await page.locator('#local-tab').click();await page.waitForFunction(()=>state.tab==='local'&&document.querySelector('#local-tab')?.classList.contains('active'));releaseSlow();await page.evaluate(()=>window.slowNavigation);assert.equal(await page.evaluate(()=>state.tab),'local');assert(await page.locator('#gallery-search').isVisible());checks++;
await page.unroute('**/api/v1/devices/*/images');
await page.evaluate(()=>{state.galleryCache?.clear();state.tab='cloud';});
await page.route('**/api/v1/devices/*/images',r=>r.fulfill({status:503,json:{error:{code:'CLOUD_UNAVAILABLE',message:'fixture unavailable'}}}));
await page.evaluate(()=>navigate('gallery'));assert(await page.locator('#gallery-search').isVisible());assert(await page.locator('#local-tab').isEnabled());assert.equal(await page.locator('#gallery-list-status').getAttribute('role'),'alert');checks++;
await page.unroute('**/api/v1/devices/*/images');
let releaseJobs,jobsRequested;await page.route('**/api/v1/jobs',async r=>{jobsRequested();await new Promise(resolve=>releaseJobs=resolve);await r.fulfill({json:[]});});
await page.route('**/api/v1/assets?*',r=>r.fulfill({json:[{asset_id:'local-fixture',name:'Local fixture',url:'/local-fixture.svg',origin:'manual',starred:false}]}));
await page.route('**/local-fixture.svg',r=>r.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"/>'}));
+32
View File
@@ -423,3 +423,35 @@ def test_scoped_client_operation_isolation(client, service):
assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [
own
]
def test_settings_exposes_storage_display_name(client, service):
service.settings.storage_name = "Family NAS"
result = client.get("/api/v1/settings").json()
assert result["storage_name"] == "Family NAS"
destinations = {d["id"]: d for d in result["gallery_destinations"]}
assert set(destinations) == {"cloud", "local"}
assert destinations["cloud"]["name"] == "KoKonna"
assert destinations["local"]["name"] == "Family NAS"
assert all(d["connected"] and d["supports_upload"] for d in destinations.values())
assert "test-frame-token" not in str(result)
def test_storage_display_name_environment(monkeypatch):
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " Family NAS ")
assert Settings().storage_name == "Family NAS"
monkeypatch.setenv("FRAME_FLOW_STORAGE_NAME", " ")
assert Settings().storage_name == "NAS"
def test_settings_device_catalog_does_not_fetch_cloud_status(
client, service, monkeypatch
):
registry = service.registry
for driver in registry.drivers.values():
monkeypatch.setattr(
driver, "device", lambda *a, **kw: pytest.fail("Cloud status fetched")
)
result = client.get("/api/v1/settings")
assert result.status_code == 200
assert {d["id"] for d in result.json()["device_catalog"]} == set(registry.drivers)