Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4543a9843 | ||
|
|
106b6f311f |
@@ -3,6 +3,8 @@ TZ=Asia/Shanghai
|
|||||||
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
|
# or explicitly 0.0.0.0. No router, reverse proxy or firewall rules are modified.
|
||||||
WEB_BIND=127.0.0.1
|
WEB_BIND=127.0.0.1
|
||||||
WEB_PORT=6005
|
WEB_PORT=6005
|
||||||
|
# Display name of the NAS/server storing the local gallery.
|
||||||
|
FRAME_FLOW_STORAGE_NAME=NAS
|
||||||
KOKONNA_MOCK=true
|
KOKONNA_MOCK=true
|
||||||
KOKONNA_WRITE_ENABLED=false
|
KOKONNA_WRITE_ENABLED=false
|
||||||
KOKONNA_API_KEY=
|
KOKONNA_API_KEY=
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
name: Verify Frame Flow
|
name: Verify Frame Flow
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Branch verification gates releases; tags do not duplicate the same build.
|
||||||
|
branches: ["**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -65,6 +67,7 @@ jobs:
|
|||||||
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
|
SYNOLOGY_PHOTOS_CERT_SHA256: ${{ secrets.SYNOLOGY_PHOTOS_CERT_SHA256 }}
|
||||||
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
|
PROD_WEB_BIND: ${{ secrets.PROD_WEB_BIND }}
|
||||||
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
|
PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }}
|
||||||
|
FRAME_FLOW_STORAGE_NAME: ${{ vars.FRAME_FLOW_STORAGE_NAME }}
|
||||||
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
|
PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }}
|
||||||
PROD_MOCK: ${{ vars.PROD_MOCK }}
|
PROD_MOCK: ${{ vars.PROD_MOCK }}
|
||||||
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
|
PROD_WRITE_ENABLED: ${{ vars.PROD_WRITE_ENABLED }}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
CONFIG_DIR: /config
|
CONFIG_DIR: /config
|
||||||
|
FRAME_FLOW_STORAGE_NAME: ${FRAME_FLOW_STORAGE_NAME:-NAS}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./config:/config
|
- ./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.
|
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.
|
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 / 验证构建启动
|
## 3. Validate, build, start / 验证构建启动
|
||||||
|
|
||||||
Synology Docker is /usr/local/bin/docker. The SSH user may require sudo. Do not
|
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
|
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
|
session disconnect is available only in the shared connection card and asks for
|
||||||
confirmation of its impact on all people-filtered sources.
|
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
@@ -39,7 +39,7 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
||||||
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
||||||
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;真实图片数量、搜索先于图片请求、无骨架 | 14 项通过 |
|
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
|
||||||
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
||||||
|
|
||||||
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
||||||
@@ -47,3 +47,16 @@ FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
|||||||
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
||||||
|
|
||||||
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
||||||
|
|
||||||
|
|
||||||
|
## 首次未缓存图库的初始化
|
||||||
|
|
||||||
|
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
|
||||||
|
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
|
||||||
|
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
|
||||||
|
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
|
||||||
|
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
|
||||||
|
|
||||||
|
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
|
||||||
|
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
|
||||||
|
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
|
||||||
|
|||||||
@@ -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_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_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
||||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
| `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_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_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
||||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
||||||
|
|||||||
+82
-3
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Pigway Frame Flow API",
|
"title": "Pigway Frame Flow API",
|
||||||
"version": "0.1.5"
|
"version": "0.1.7"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||||
@@ -14819,6 +14819,24 @@
|
|||||||
],
|
],
|
||||||
"title": "Device"
|
"title": "Device"
|
||||||
},
|
},
|
||||||
|
"DeviceIdentity": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"title": "DeviceIdentity"
|
||||||
|
},
|
||||||
"DisplayGeometry": {
|
"DisplayGeometry": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"width": {
|
"width": {
|
||||||
@@ -15047,6 +15065,48 @@
|
|||||||
],
|
],
|
||||||
"title": "FutureContent"
|
"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": {
|
"GalleryImage": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"starred": {
|
"starred": {
|
||||||
@@ -15156,7 +15216,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.5"
|
"default": "0.1.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -17091,6 +17151,25 @@
|
|||||||
},
|
},
|
||||||
"SettingsView": {
|
"SettingsView": {
|
||||||
"properties": {
|
"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": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version"
|
"title": "Version"
|
||||||
@@ -17333,7 +17412,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.5"
|
"default": "0.1.7"
|
||||||
},
|
},
|
||||||
"api_version": {
|
"api_version": {
|
||||||
"type": "string",
|
"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.
|
||||||
@@ -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,9 +33,11 @@ from pigway_frame_flow.models import (
|
|||||||
CurrentDisplay,
|
CurrentDisplay,
|
||||||
DeleteRequest,
|
DeleteRequest,
|
||||||
Device,
|
Device,
|
||||||
|
DeviceIdentity,
|
||||||
DisplayRequest,
|
DisplayRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
Fit,
|
Fit,
|
||||||
|
GalleryDestination,
|
||||||
GalleryImage,
|
GalleryImage,
|
||||||
Health,
|
Health,
|
||||||
History,
|
History,
|
||||||
@@ -124,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Pigway Frame Flow API",
|
title="Pigway Frame Flow API",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
version="0.1.5",
|
version="0.1.7",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
responses=error_responses,
|
responses=error_responses,
|
||||||
)
|
)
|
||||||
@@ -1000,7 +1002,36 @@ def create_app(settings=None, start_worker=True):
|
|||||||
)
|
)
|
||||||
def settings_view():
|
def settings_view():
|
||||||
return SettingsView(
|
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,
|
mock=settings.mock,
|
||||||
write_enabled=settings.write_enabled,
|
write_enabled=settings.write_enabled,
|
||||||
api_key_configured=bool(settings.api_key)
|
api_key_configured=bool(settings.api_key)
|
||||||
|
|||||||
@@ -333,7 +333,24 @@ class History(BaseModel):
|
|||||||
timestamp: str
|
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):
|
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
|
version: str
|
||||||
mock: bool
|
mock: bool
|
||||||
write_enabled: bool
|
write_enabled: bool
|
||||||
@@ -346,7 +363,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.5"
|
version: str = "0.1.7"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -376,7 +393,7 @@ class RestoreRequest(StrictModel):
|
|||||||
|
|
||||||
class Version(BaseModel):
|
class Version(BaseModel):
|
||||||
product: str = "Pigway Frame Flow"
|
product: str = "Pigway Frame Flow"
|
||||||
version: str = "0.1.5"
|
version: str = "0.1.7"
|
||||||
api_version: str = "v1"
|
api_version: str = "v1"
|
||||||
commit: str = "development"
|
commit: str = "development"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class Settings:
|
|||||||
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
default_factory=lambda: secret("FRAME_FLOW_TOKEN"), repr=False
|
||||||
)
|
)
|
||||||
additional_secrets: list[str] = field(default_factory=list, 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"
|
device_id: str = "living-room"
|
||||||
timezone: str = "Asia/Shanghai"
|
timezone: str = "Asia/Shanghai"
|
||||||
cors_origins: list[str] = field(
|
cors_origins: list[str] = field(
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const $=s=>document.querySelector(s);
|
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};
|
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=[];}
|
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,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;}
|
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 icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',studio:'▦',automation:'◷',playlists:'▤',history:'↺',connections:'↔',settings:'⚙'};
|
||||||
const t=k=>window.MESSAGES[state.lang][k]||k;
|
const t=k=>window.MESSAGES[state.lang][k]||k;
|
||||||
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;}
|
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);
|
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();}
|
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){
|
function imageLoading(img){
|
||||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||||
const box=document.createElement('div');box.className='image-loading';
|
const box=document.createElement('div');box.className='image-loading';
|
||||||
@@ -102,15 +116,22 @@ const views={
|
|||||||
studio:()=>layoutStudioPage(),
|
studio:()=>layoutStudioPage(),
|
||||||
connections:()=>connectionsPage(),
|
connections:()=>connectionsPage(),
|
||||||
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
|
'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){
|
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 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 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,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();});});};
|
||||||
if(cached)render(cached);
|
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):''));
|
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(epoch!==state.epoch)return;cache.set(key,items);
|
||||||
if(!cached||JSON.stringify(cached)!==JSON.stringify(items))render(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 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;}},
|
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;}},
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ function displayDialog(content){
|
|||||||
function enhanceGallery(){
|
function enhanceGallery(){
|
||||||
const grid=$('.gallery');if(!grid)return;
|
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);
|
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();});}}
|
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=()=>{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));};
|
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'));});}});
|
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(){
|
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;
|
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,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!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>
|
<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>
|
||||||
|
|||||||
@@ -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-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-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}
|
.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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pigway-frame-flow"
|
name = "pigway-frame-flow"
|
||||||
version = "0.1.5"
|
version = "0.1.7"
|
||||||
requires-python = ">=3.12"
|
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"]
|
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]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -403,7 +403,11 @@ class Deploy:
|
|||||||
service["image"] = latest
|
service["image"] = latest
|
||||||
proposed = self.secret_config()
|
proposed = self.secret_config()
|
||||||
service["environment"].update(proposed)
|
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):
|
if self.env.get(key):
|
||||||
service["environment"][key] = self.env[key]
|
service["environment"][key] = self.env[key]
|
||||||
service["environment"].update(
|
service["environment"].update(
|
||||||
|
|||||||
@@ -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)});
|
||||||
@@ -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)});
|
||||||
@@ -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);
|
const metadata=new Promise(resolve=>metadataRequested=resolve);const image=new Promise(resolve=>imageRequested=resolve);
|
||||||
await page.evaluate(()=>{window.navigation=navigate('gallery');});await metadata;
|
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.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);
|
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++;
|
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++;
|
releaseImage();await page.waitForFunction(()=>document.querySelector('.thumb')?.naturalWidth===300&&!document.querySelector('.image-loading'));checks++;
|
||||||
await page.evaluate(()=>navigate('upload'));
|
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++;
|
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');
|
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:[]});});
|
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('**/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"/>'}));
|
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"/>'}));
|
||||||
|
|||||||
@@ -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()] == [
|
assert [op["operation_id"] for op in client.get("/api/v1/operations").json()] == [
|
||||||
own
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user