Release v0.1.6: identify gallery destinations by provider and NAS name [skip ci]
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 仅作为只读照片来源,
|
||||
不出现在可保存目的地标签栏,不显示尚未实现的上传入口。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+57
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Pigway Frame Flow API",
|
||||
"version": "0.1.5"
|
||||
"version": "0.1.6"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15047,6 +15047,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 +15198,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.5"
|
||||
"default": "0.1.6"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17091,6 +17133,18 @@
|
||||
},
|
||||
"SettingsView": {
|
||||
"properties": {
|
||||
"storage_name": {
|
||||
"type": "string",
|
||||
"title": "Storage Name",
|
||||
"default": "NAS"
|
||||
},
|
||||
"gallery_destinations": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GalleryDestination"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Gallery Destinations"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version"
|
||||
@@ -17333,7 +17387,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.5"
|
||||
"default": "0.1.6"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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.
|
||||
@@ -36,6 +36,7 @@ from pigway_frame_flow.models import (
|
||||
DisplayRequest,
|
||||
ErrorResponse,
|
||||
Fit,
|
||||
GalleryDestination,
|
||||
GalleryImage,
|
||||
Health,
|
||||
History,
|
||||
@@ -124,7 +125,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.6",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1000,7 +1001,28 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def settings_view():
|
||||
return SettingsView(
|
||||
version="0.1.5",
|
||||
storage_name=settings.storage_name,
|
||||
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.6",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -333,7 +333,18 @@ 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 SettingsView(BaseModel):
|
||||
storage_name: str = "NAS"
|
||||
gallery_destinations: list[GalleryDestination] = Field(default_factory=list)
|
||||
version: str
|
||||
mock: bool
|
||||
write_enabled: bool
|
||||
@@ -346,7 +357,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.5"
|
||||
version: str = "0.1.6"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -376,7 +387,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "Pigway Frame Flow"
|
||||
version: str = "0.1.5"
|
||||
version: str = "0.1.6"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -30,6 +30,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';
|
||||
@@ -104,9 +113,10 @@ const views={
|
||||
'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 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();});});};
|
||||
const render=items=>{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)}</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 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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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.6</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>
|
||||
|
||||
@@ -146,3 +146,6 @@ 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}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)});
|
||||
@@ -423,3 +423,22 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user