Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b374d7eb87 | |||
| d20def9f66 | |||
| 13e8a0121f | |||
| fe4c0a95d5 | |||
| c5abd18a64 | |||
| cf1a9ed3f5 | |||
| feacf9b134 | |||
| 67eb33b3a7 | |||
| 23e3d43578 | |||
| 028c8a2276 | |||
| b7f4cc8171 | |||
| 5c11d15008 |
@@ -0,0 +1,406 @@
|
|||||||
|
name: Visual Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
visual-diff:
|
||||||
|
name: Visual Diff Screenshots
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
(github.event.action == 'labeled' && github.event.label.name == 'visual-review') ||
|
||||||
|
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'visual-review'))
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./e2e
|
||||||
|
steps:
|
||||||
|
- id: token
|
||||||
|
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
|
- name: Checkout PR branch
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Determine changed web files
|
||||||
|
id: changed-files
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const files = [];
|
||||||
|
const perPage = 100;
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const { data } = await github.rest.pulls.listFiles({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
per_page: perPage,
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
files.push(...data);
|
||||||
|
if (data.length < perPage) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webPrefixes = ['web/', 'i18n/', 'open-api/typescript-sdk/'];
|
||||||
|
const webFiles = files
|
||||||
|
.map(f => f.filename)
|
||||||
|
.filter(f => webPrefixes.some(p => f.startsWith(p)));
|
||||||
|
|
||||||
|
console.log(`Total PR files: ${files.length}`);
|
||||||
|
console.log(`Web-related files: ${webFiles.length}`);
|
||||||
|
for (const f of webFiles) {
|
||||||
|
console.log(` ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.setOutput('files', webFiles.join('\n'));
|
||||||
|
core.setOutput('has_changes', webFiles.length > 0 ? 'true' : 'false');
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
|
with:
|
||||||
|
node-version-file: './e2e/.nvmrc'
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
- name: Install e2e dependencies
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
run: pnpm exec playwright install chromium --only-shell
|
||||||
|
|
||||||
|
- name: Analyze affected routes
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true'
|
||||||
|
id: routes
|
||||||
|
env:
|
||||||
|
CHANGED_FILES: ${{ steps.changed-files.outputs.files }}
|
||||||
|
run: |
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED_FILES"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
ROUTES=$(echo "$CHANGED_FILES" | xargs pnpm exec tsx src/screenshots/analyze-deps.ts 2>&1 | tee /dev/stderr | grep "^ /" | sed 's/^ //' || true)
|
||||||
|
|
||||||
|
echo "routes<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$ROUTES" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
if [ -z "$ROUTES" ]; then
|
||||||
|
echo "has_routes=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_routes=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Build the scenario filter JSON array
|
||||||
|
SCENARIO_NAMES=$(pnpm exec tsx -e "
|
||||||
|
import { getScenariosForRoutes } from './src/screenshots/page-map.ts';
|
||||||
|
const routes = process.argv.slice(1);
|
||||||
|
const scenarios = getScenariosForRoutes(routes);
|
||||||
|
console.log(JSON.stringify(scenarios.map(s => s.name)));
|
||||||
|
" $ROUTES)
|
||||||
|
echo "scenarios=$SCENARIO_NAMES" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Scenarios: $SCENARIO_NAMES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Post initial comment
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
AFFECTED_ROUTES: ${{ steps.routes.outputs.routes }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const routes = process.env.AFFECTED_ROUTES || '';
|
||||||
|
const body = `## Visual Review\n\nGenerating screenshots for affected pages...\n\nAffected routes:\n\`\`\`\n${routes}\n\`\`\``;
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
# === Screenshot PR version ===
|
||||||
|
- name: Build SDK (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
|
- name: Build web (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
run: pnpm install --frozen-lockfile && pnpm build
|
||||||
|
working-directory: ./web
|
||||||
|
|
||||||
|
- name: Take screenshots (PR)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||||
|
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/pr
|
||||||
|
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||||
|
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||||
|
run: |
|
||||||
|
# Start the preview server in background
|
||||||
|
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||||
|
echo "Server ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run screenshot tests
|
||||||
|
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||||
|
|
||||||
|
# Stop the preview server and all children (pnpm spawns vite as child)
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
# Ensure port is fully released — kill any lingering vite process
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# === Screenshot base version ===
|
||||||
|
# Disable pnpm's verifyDepsBeforeRun for all base steps since the base
|
||||||
|
# checkout changes package.json files, making them mismatch the lockfile.
|
||||||
|
- name: Checkout base web directory
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
# Restore web directory from base branch
|
||||||
|
git checkout "$BASE_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||||
|
# Clear SvelteKit build cache to avoid stale artifacts from the PR build
|
||||||
|
rm -rf web/.svelte-kit web/build
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
- name: Build SDK (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
id: base-sdk
|
||||||
|
env:
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: ./open-api/typescript-sdk
|
||||||
|
|
||||||
|
- name: Build web (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true' && steps.base-sdk.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
|
id: base-web
|
||||||
|
env:
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: pnpm build
|
||||||
|
working-directory: ./web
|
||||||
|
|
||||||
|
- name: Take screenshots (base)
|
||||||
|
if: steps.routes.outputs.has_routes == 'true' && steps.base-web.outcome == 'success'
|
||||||
|
env:
|
||||||
|
PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1'
|
||||||
|
SCREENSHOT_OUTPUT_DIR: ${{ github.workspace }}/screenshots/base
|
||||||
|
SCREENSHOT_SCENARIOS: ${{ steps.routes.outputs.scenarios }}
|
||||||
|
SCREENSHOT_BASE_URL: http://127.0.0.1:4173
|
||||||
|
PNPM_VERIFY_DEPS_BEFORE_RUN: 'false'
|
||||||
|
run: |
|
||||||
|
# Kill any process still on port 4173 from the PR step
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Start the preview server in background
|
||||||
|
cd ../web && pnpm preview --port 4173 --host 127.0.0.1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
# Wait for server to be ready
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -s http://127.0.0.1:4173 > /dev/null 2>&1; then
|
||||||
|
echo "Server ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Run screenshot tests
|
||||||
|
pnpm exec playwright test --config playwright.screenshot.config.ts || true
|
||||||
|
|
||||||
|
# Stop the preview server
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
fuser -k 4173/tcp 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Restore PR source
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
run: |
|
||||||
|
git checkout "$HEAD_SHA" -- web/ open-api/typescript-sdk/ i18n/ || true
|
||||||
|
working-directory: .
|
||||||
|
|
||||||
|
# === Compare and report ===
|
||||||
|
- name: Compare screenshots
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
env:
|
||||||
|
WORKSPACE_DIR: ${{ github.workspace }}
|
||||||
|
run: |
|
||||||
|
# Ensure directories exist even if base screenshots were skipped
|
||||||
|
mkdir -p "$WORKSPACE_DIR/screenshots/base" "$WORKSPACE_DIR/screenshots/pr" "$WORKSPACE_DIR/screenshots/diff"
|
||||||
|
pnpm exec tsx src/screenshots/compare.ts \
|
||||||
|
"$WORKSPACE_DIR/screenshots/base" \
|
||||||
|
"$WORKSPACE_DIR/screenshots/pr" \
|
||||||
|
"$WORKSPACE_DIR/screenshots/diff"
|
||||||
|
|
||||||
|
- name: Upload screenshot artifacts
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: visual-review-screenshots
|
||||||
|
path: screenshots/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Upload HTML report
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
id: html-report
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
path: screenshots/diff/visual-review.html
|
||||||
|
archive: false
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Post comparison results
|
||||||
|
if: steps.routes.outputs.has_routes == 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
env:
|
||||||
|
REPORT_URL: ${{ steps.html-report.outputs.artifact-url }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const reportPath = path.join(process.env.GITHUB_WORKSPACE, 'screenshots', 'diff', 'report.md');
|
||||||
|
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = fs.readFileSync(reportPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
body = '## Visual Review\n\nScreenshot comparison failed. Check the workflow artifacts for details.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append links to the HTML report artifact and workflow run
|
||||||
|
const reportUrl = process.env.REPORT_URL;
|
||||||
|
const runUrl = process.env.RUN_URL;
|
||||||
|
body += '\n---\n';
|
||||||
|
if (reportUrl) {
|
||||||
|
body += `[View full visual comparison](${reportUrl}) | `;
|
||||||
|
}
|
||||||
|
body += `[Download all screenshots](${runUrl}#artifacts)\n`;
|
||||||
|
|
||||||
|
// Find and update existing comment or create new one
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botComment = comments.data.find(c =>
|
||||||
|
c.body && c.body.includes('## Visual Review')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (botComment) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: botComment.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: No web changes
|
||||||
|
if: steps.changed-files.outputs.has_changes != 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const body = '## Visual Review\n\nNo web-related file changes detected in this PR. Visual review not needed.';
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
comment_id: existing.id, body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number, body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: No affected routes
|
||||||
|
if: steps.changed-files.outputs.has_changes == 'true' && steps.routes.outputs.has_routes != 'true'
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const body = '## Visual Review\n\nChanged files don\'t affect any pages with screenshot scenarios configured.\nTo add coverage, define new scenarios in `e2e/src/screenshots/page-map.ts`.';
|
||||||
|
const comments = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.data.find(c => c.body && c.body.includes('## Visual Review'));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
comment_id: existing.id, body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number, body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ open-api/typescript-sdk/build
|
|||||||
mobile/android/fastlane/report.xml
|
mobile/android/fastlane/report.xml
|
||||||
mobile/ios/fastlane/report.xml
|
mobile/ios/fastlane/report.xml
|
||||||
|
|
||||||
|
screenshots-output
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.devcontainer/library
|
.devcontainer/library
|
||||||
|
|||||||
@@ -18,7 +18,10 @@
|
|||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||||
"lint:fix": "pnpm run lint --fix",
|
"lint:fix": "pnpm run lint --fix",
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit",
|
||||||
|
"screenshots": "pnpm exec playwright test --config playwright.screenshot.config.ts",
|
||||||
|
"screenshots:compare": "pnpm exec tsx src/screenshots/compare.ts",
|
||||||
|
"screenshots:analyze": "pnpm exec tsx src/screenshots/analyze-deps.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"utimes": "^5.2.1",
|
"utimes": "^5.2.1",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseUrl = process.env.SCREENSHOT_BASE_URL ?? 'http://127.0.0.1:4173';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './src/screenshots',
|
||||||
|
testMatch: /run-scenarios\.ts/,
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
screenshot: 'off',
|
||||||
|
trace: 'off',
|
||||||
|
},
|
||||||
|
workers: 1,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'screenshots',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1920, height: 1080 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Reverse dependency analyzer for the Immich web app.
|
||||||
|
*
|
||||||
|
* Given a list of changed files, traces upward through the import graph
|
||||||
|
* to find which +page.svelte routes are affected, then maps those to URL paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const WEB_SRC = resolve(import.meta.dirname, '../../../web/src');
|
||||||
|
const LIB_ALIAS = resolve(WEB_SRC, 'lib');
|
||||||
|
|
||||||
|
/** Collect all .svelte, .ts, .js files under web/src/ */
|
||||||
|
function collectFiles(dir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
const stat = statSync(full);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (entry === 'node_modules' || entry === '.svelte-kit') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(...collectFiles(full));
|
||||||
|
} else if (/\.(svelte|ts|js)$/.test(entry)) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract import specifiers from a file's source text. */
|
||||||
|
function extractImports(source: string): string[] {
|
||||||
|
const specifiers: string[] = [];
|
||||||
|
|
||||||
|
// Match: import ... from '...' / import '...' / export ... from '...'
|
||||||
|
const importRegex = /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||||
|
let match;
|
||||||
|
while ((match = importRegex.exec(source)) !== null) {
|
||||||
|
specifiers.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match dynamic imports: import('...')
|
||||||
|
const dynamicRegex = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
while ((match = dynamicRegex.exec(source)) !== null) {
|
||||||
|
specifiers.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return specifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an import specifier to an absolute file path (or null if external). */
|
||||||
|
function resolveImport(specifier: string, fromFile: string, allFiles: Set<string>): string | null {
|
||||||
|
// Handle $lib alias
|
||||||
|
let resolved: string;
|
||||||
|
if (specifier.startsWith('$lib/') || specifier === '$lib') {
|
||||||
|
resolved = specifier.replace('$lib', LIB_ALIAS);
|
||||||
|
} else if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||||
|
resolved = resolve(dirname(fromFile), specifier);
|
||||||
|
} else {
|
||||||
|
// External package import — not relevant
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match, then common extensions
|
||||||
|
const extensions = ['', '.ts', '.js', '.svelte', '/index.ts', '/index.js', '/index.svelte'];
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = resolved + ext;
|
||||||
|
if (allFiles.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the forward dependency graph: file → set of files it imports. */
|
||||||
|
function buildDependencyGraph(files: string[]): Map<string, Set<string>> {
|
||||||
|
const fileSet = new Set(files);
|
||||||
|
const graph = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const deps = new Set<string>();
|
||||||
|
graph.set(file, deps);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = readFileSync(file, 'utf8');
|
||||||
|
for (const specifier of extractImports(source)) {
|
||||||
|
const resolved = resolveImport(specifier, file, fileSet);
|
||||||
|
if (resolved) {
|
||||||
|
deps.add(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invert the dependency graph: file → set of files that import it. */
|
||||||
|
function buildReverseDependencyGraph(forwardGraph: Map<string, Set<string>>): Map<string, Set<string>> {
|
||||||
|
const reverse = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const [file, deps] of forwardGraph) {
|
||||||
|
for (const dep of deps) {
|
||||||
|
let importers = reverse.get(dep);
|
||||||
|
if (!importers) {
|
||||||
|
importers = new Set();
|
||||||
|
reverse.set(dep, importers);
|
||||||
|
}
|
||||||
|
importers.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BFS from changed files upward through reverse deps to find +page.svelte files. */
|
||||||
|
function findAffectedPages(changedFiles: string[], reverseGraph: Map<string, Set<string>>): Set<string> {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const pages = new Set<string>();
|
||||||
|
const queue = [...changedFiles];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const file = queue.shift()!;
|
||||||
|
if (visited.has(file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.add(file);
|
||||||
|
|
||||||
|
if (file.endsWith('+page.svelte') || file.endsWith('+layout.svelte')) {
|
||||||
|
pages.add(file);
|
||||||
|
// If it's a layout, keep tracing upward because the layout itself
|
||||||
|
// isn't a page — but the pages under it are affected.
|
||||||
|
// If it's a +page.svelte, we still want to continue in case
|
||||||
|
// this page is imported by others.
|
||||||
|
}
|
||||||
|
|
||||||
|
const importers = reverseGraph.get(file);
|
||||||
|
if (importers) {
|
||||||
|
for (const importer of importers) {
|
||||||
|
if (!visited.has(importer)) {
|
||||||
|
queue.push(importer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For +layout.svelte hits, also find all +page.svelte under the same directory tree
|
||||||
|
const layoutDirs: string[] = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.endsWith('+layout.svelte')) {
|
||||||
|
layoutDirs.push(dirname(page));
|
||||||
|
pages.delete(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutDirs.length > 0) {
|
||||||
|
for (const file of reverseGraph.keys()) {
|
||||||
|
if (file.endsWith('+page.svelte')) {
|
||||||
|
for (const layoutDir of layoutDirs) {
|
||||||
|
if (file.startsWith(layoutDir)) {
|
||||||
|
pages.add(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check the forward graph keys for page files under layout dirs
|
||||||
|
for (const layoutDir of layoutDirs) {
|
||||||
|
const allFiles = collectFiles(layoutDir);
|
||||||
|
for (const f of allFiles) {
|
||||||
|
if (f.endsWith('+page.svelte')) {
|
||||||
|
pages.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a +page.svelte file path to its URL route. */
|
||||||
|
export function pageFileToRoute(pageFile: string): string {
|
||||||
|
const routesDir = resolve(WEB_SRC, 'routes');
|
||||||
|
let rel = relative(routesDir, dirname(pageFile));
|
||||||
|
|
||||||
|
// Remove SvelteKit group markers: (user), (list), etc.
|
||||||
|
rel = rel.replaceAll(/\([^)]+\)\/?/g, '');
|
||||||
|
|
||||||
|
// Remove parameter segments: [albumId=id], [[photos=photos]], [[assetId=id]]
|
||||||
|
rel = rel.replaceAll(/\[\[?[^\]]+\]\]?\/?/g, '');
|
||||||
|
|
||||||
|
// Clean up trailing slashes and normalize
|
||||||
|
rel = rel.replaceAll(/\/+/g, '/').replace(/\/$/, '');
|
||||||
|
|
||||||
|
return '/' + rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
affectedPages: string[];
|
||||||
|
affectedRoutes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main entry: analyze which routes are affected by the given changed files. */
|
||||||
|
export function analyzeAffectedRoutes(changedFiles: string[]): AnalysisResult {
|
||||||
|
// Resolve changed files to absolute paths relative to web/src
|
||||||
|
const webRoot = resolve(WEB_SRC, '..');
|
||||||
|
const resolvedChanged = changedFiles
|
||||||
|
.filter((f) => f.startsWith('web/'))
|
||||||
|
.map((f) => resolve(webRoot, '..', f))
|
||||||
|
.filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
|
||||||
|
|
||||||
|
if (resolvedChanged.length === 0) {
|
||||||
|
return { affectedPages: [], affectedRoutes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = collectFiles(WEB_SRC);
|
||||||
|
const forwardGraph = buildDependencyGraph(allFiles);
|
||||||
|
const reverseGraph = buildReverseDependencyGraph(forwardGraph);
|
||||||
|
|
||||||
|
const pages = findAffectedPages(resolvedChanged, reverseGraph);
|
||||||
|
|
||||||
|
const affectedPages = [...pages].toSorted();
|
||||||
|
const affectedRoutes = [...new Set(affectedPages.map((f) => pageFileToRoute(f)))].toSorted();
|
||||||
|
|
||||||
|
return { affectedPages, affectedRoutes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI usage: node --import tsx analyze-deps.ts file1 file2 ...
|
||||||
|
if (process.argv[1]?.endsWith('analyze-deps.ts') || process.argv[1]?.endsWith('analyze-deps.js')) {
|
||||||
|
const files = process.argv.slice(2);
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log('Usage: analyze-deps.ts <changed-file1> <changed-file2> ...');
|
||||||
|
console.log('Files should be relative to the repo root (e.g. web/src/lib/components/Button.svelte)');
|
||||||
|
throw new Error('No files provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = analyzeAffectedRoutes(files);
|
||||||
|
console.log('Affected pages:');
|
||||||
|
for (const page of result.affectedPages) {
|
||||||
|
console.log(` ${page}`);
|
||||||
|
}
|
||||||
|
console.log('\nAffected routes:');
|
||||||
|
for (const route of result.affectedRoutes) {
|
||||||
|
console.log(` ${route}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Pixel-level comparison of base vs PR screenshots.
|
||||||
|
*
|
||||||
|
* Uses pixelmatch to generate diff images and calculate change percentages.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx e2e/src/screenshots/compare.ts <base-dir> <pr-dir> <output-dir>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { basename, join, resolve } from 'node:path';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
|
||||||
|
// pixelmatch is a lightweight dependency — use a simple inline implementation
|
||||||
|
// based on the approach from the pixelmatch library to avoid adding a new dependency.
|
||||||
|
// The e2e package already has pngjs.
|
||||||
|
|
||||||
|
function pixelMatch(img1Data: Uint8Array, img2Data: Uint8Array, diffData: Uint8Array): number {
|
||||||
|
let diffCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < img1Data.length; i += 4) {
|
||||||
|
const r1 = img1Data[i];
|
||||||
|
const g1 = img1Data[i + 1];
|
||||||
|
const b1 = img1Data[i + 2];
|
||||||
|
|
||||||
|
const r2 = img2Data[i];
|
||||||
|
const g2 = img2Data[i + 1];
|
||||||
|
const b2 = img2Data[i + 2];
|
||||||
|
|
||||||
|
const dr = Math.abs(r1 - r2);
|
||||||
|
const dg = Math.abs(g1 - g2);
|
||||||
|
const db = Math.abs(b1 - b2);
|
||||||
|
|
||||||
|
// Threshold: if any channel differs by more than 25, mark as different
|
||||||
|
const isDiff = dr > 25 || dg > 25 || db > 25;
|
||||||
|
|
||||||
|
if (isDiff) {
|
||||||
|
// Red highlight for diff pixels
|
||||||
|
diffData[i] = 255;
|
||||||
|
diffData[i + 1] = 0;
|
||||||
|
diffData[i + 2] = 0;
|
||||||
|
diffData[i + 3] = 255;
|
||||||
|
diffCount++;
|
||||||
|
} else {
|
||||||
|
// Dimmed original for unchanged pixels
|
||||||
|
const gray = Math.round(0.299 * r1 + 0.587 * g1 + 0.114 * b1);
|
||||||
|
diffData[i] = gray;
|
||||||
|
diffData[i + 1] = gray;
|
||||||
|
diffData[i + 2] = gray;
|
||||||
|
diffData[i + 3] = 128;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonResult {
|
||||||
|
name: string;
|
||||||
|
baseExists: boolean;
|
||||||
|
prExists: boolean;
|
||||||
|
diffPixels: number;
|
||||||
|
totalPixels: number;
|
||||||
|
changePercent: number;
|
||||||
|
diffImagePath: string | null;
|
||||||
|
baseImagePath: string | null;
|
||||||
|
prImagePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareScreenshots(baseDir: string, prDir: string, outputDir: string): ComparisonResult[] {
|
||||||
|
mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
// Collect all screenshot names from both directories
|
||||||
|
const baseFiles = existsSync(baseDir)
|
||||||
|
? new Set(readdirSync(baseDir).filter((f) => f.endsWith('.png')))
|
||||||
|
: new Set<string>();
|
||||||
|
const prFiles = existsSync(prDir) ? new Set(readdirSync(prDir).filter((f) => f.endsWith('.png'))) : new Set<string>();
|
||||||
|
|
||||||
|
const allNames = new Set([...baseFiles, ...prFiles]);
|
||||||
|
const results: ComparisonResult[] = [];
|
||||||
|
|
||||||
|
for (const fileName of [...allNames].toSorted()) {
|
||||||
|
const name = basename(fileName, '.png');
|
||||||
|
const basePath = join(baseDir, fileName);
|
||||||
|
const prPath = join(prDir, fileName);
|
||||||
|
const baseExists = baseFiles.has(fileName);
|
||||||
|
const prExists = prFiles.has(fileName);
|
||||||
|
|
||||||
|
if (!baseExists || !prExists) {
|
||||||
|
// New or removed page
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
baseExists,
|
||||||
|
prExists,
|
||||||
|
diffPixels: -1,
|
||||||
|
totalPixels: -1,
|
||||||
|
changePercent: 100,
|
||||||
|
diffImagePath: null,
|
||||||
|
baseImagePath: baseExists ? basePath : null,
|
||||||
|
prImagePath: prExists ? prPath : null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load both PNGs
|
||||||
|
const basePng = PNG.sync.read(readFileSync(basePath));
|
||||||
|
const prPng = PNG.sync.read(readFileSync(prPath));
|
||||||
|
|
||||||
|
// Handle size mismatches by comparing the overlapping region
|
||||||
|
const width = Math.max(basePng.width, prPng.width);
|
||||||
|
const height = Math.max(basePng.height, prPng.height);
|
||||||
|
|
||||||
|
// Resize images to the same dimensions (pad with transparent)
|
||||||
|
const normalizedBase = normalizeImage(basePng, width, height);
|
||||||
|
const normalizedPr = normalizeImage(prPng, width, height);
|
||||||
|
|
||||||
|
const diffPng = new PNG({ width, height });
|
||||||
|
const totalPixels = width * height;
|
||||||
|
const diffPixels = pixelMatch(normalizedBase, normalizedPr, diffPng.data as unknown as Uint8Array);
|
||||||
|
|
||||||
|
const diffImagePath = join(outputDir, `${name}-diff.png`);
|
||||||
|
writeFileSync(diffImagePath, PNG.sync.write(diffPng));
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
baseExists,
|
||||||
|
prExists,
|
||||||
|
diffPixels,
|
||||||
|
totalPixels,
|
||||||
|
changePercent: totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0,
|
||||||
|
diffImagePath,
|
||||||
|
baseImagePath: basePath,
|
||||||
|
prImagePath: prPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImage(png: PNG, targetWidth: number, targetHeight: number): Uint8Array {
|
||||||
|
if (png.width === targetWidth && png.height === targetHeight) {
|
||||||
|
return png.data as unknown as Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(targetWidth * targetHeight * 4);
|
||||||
|
for (let y = 0; y < targetHeight; y++) {
|
||||||
|
for (let x = 0; x < targetWidth; x++) {
|
||||||
|
const targetIdx = (y * targetWidth + x) * 4;
|
||||||
|
if (x < png.width && y < png.height) {
|
||||||
|
const sourceIdx = (y * png.width + x) * 4;
|
||||||
|
data[targetIdx] = png.data[sourceIdx];
|
||||||
|
data[targetIdx + 1] = png.data[sourceIdx + 1];
|
||||||
|
data[targetIdx + 2] = png.data[sourceIdx + 2];
|
||||||
|
data[targetIdx + 3] = png.data[sourceIdx + 3];
|
||||||
|
} else {
|
||||||
|
// Transparent padding
|
||||||
|
data[targetIdx + 3] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a text-only markdown summary for the PR comment. */
|
||||||
|
export function generateMarkdownReport(results: ComparisonResult[]): string {
|
||||||
|
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||||
|
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
return '## Visual Review\n\nNo visual changes detected in the affected pages.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let md = '## Visual Review\n\n';
|
||||||
|
md += `Found **${changed.length}** page(s) with visual changes`;
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
md += ` (${unchanged.length} unchanged)`;
|
||||||
|
}
|
||||||
|
md += '.\n\n';
|
||||||
|
|
||||||
|
md += '| Page | Status | Change |\n';
|
||||||
|
md += '|------|--------|--------|\n';
|
||||||
|
|
||||||
|
for (const result of changed) {
|
||||||
|
if (result.baseExists && result.prExists) {
|
||||||
|
md += `| ${result.name} | Changed | ${result.changePercent.toFixed(1)}% |\n`;
|
||||||
|
} else if (result.prExists) {
|
||||||
|
md += `| ${result.name} | New | - |\n`;
|
||||||
|
} else {
|
||||||
|
md += `| ${result.name} | Removed | - |\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md += '\n';
|
||||||
|
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
md += '<details>\n<summary>Unchanged pages</summary>\n\n';
|
||||||
|
for (const result of unchanged) {
|
||||||
|
md += `- ${result.name}\n`;
|
||||||
|
}
|
||||||
|
md += '\n</details>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgTag(filePath: string | null, alt: string): string {
|
||||||
|
if (!filePath || !existsSync(filePath)) {
|
||||||
|
return `<div class="no-image">${alt} not available</div>`;
|
||||||
|
}
|
||||||
|
const data = readFileSync(filePath);
|
||||||
|
return `<img src="data:image/png;base64,${data.toString('base64')}" alt="${alt}" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate an HTML report with embedded base64 images for the artifact. */
|
||||||
|
export function generateHtmlReport(results: ComparisonResult[]): string {
|
||||||
|
const changed = results.filter((r) => r.changePercent > 0.1);
|
||||||
|
const unchanged = results.filter((r) => r.changePercent <= 0.1);
|
||||||
|
|
||||||
|
let html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Visual Review</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
background: #0d1117; color: #e6edf3; padding: 32px; line-height: 1.5; }
|
||||||
|
.container { max-width: 1800px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 24px; }
|
||||||
|
.summary { color: #8b949e; margin-bottom: 32px; font-size: 16px; }
|
||||||
|
.scenario { margin-bottom: 40px; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
|
||||||
|
.scenario-header { background: #161b22; padding: 12px 16px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.scenario-header h2 { font-size: 16px; font-weight: 600; }
|
||||||
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||||
|
.badge-changed { background: #da363380; color: #f85149; }
|
||||||
|
.badge-new { background: #1f6feb80; color: #58a6ff; }
|
||||||
|
.badge-removed { background: #6e767e80; color: #8b949e; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #30363d; }
|
||||||
|
.grid-cell { background: #0d1117; }
|
||||||
|
.grid-label { text-align: center; padding: 8px; font-size: 13px; color: #8b949e; font-weight: 600;
|
||||||
|
background: #161b22; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.grid-cell img { width: 100%; display: block; }
|
||||||
|
.no-image { padding: 40px; text-align: center; color: #484f58; font-style: italic; }
|
||||||
|
.unchanged-section { margin-top: 32px; color: #8b949e; }
|
||||||
|
.unchanged-section summary { cursor: pointer; font-size: 14px; }
|
||||||
|
.unchanged-section ul { margin-top: 8px; padding-left: 24px; }
|
||||||
|
.unchanged-section li { font-size: 14px; margin: 4px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Visual Review</h1>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (changed.length === 0) {
|
||||||
|
html += '<p class="summary">No visual changes detected in the affected pages.</p>';
|
||||||
|
} else {
|
||||||
|
html += `<p class="summary">Found <strong>${changed.length}</strong> page(s) with visual changes`;
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
html += ` (${unchanged.length} unchanged)`;
|
||||||
|
}
|
||||||
|
html += '.</p>\n';
|
||||||
|
|
||||||
|
for (const result of changed) {
|
||||||
|
html += '<div class="scenario">\n<div class="scenario-header">\n';
|
||||||
|
html += `<h2>${result.name}</h2>\n`;
|
||||||
|
|
||||||
|
if (!result.baseExists) {
|
||||||
|
html += '<span class="badge badge-new">New</span>\n';
|
||||||
|
html += '</div>\n';
|
||||||
|
html += `<div style="padding: 16px;">${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||||
|
html += '</div>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.prExists) {
|
||||||
|
html += '<span class="badge badge-removed">Removed</span>\n';
|
||||||
|
html += '</div>\n</div>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<span class="badge badge-changed">${result.changePercent.toFixed(1)}% changed</span>\n`;
|
||||||
|
html += '</div>\n';
|
||||||
|
html += '<div class="grid">\n';
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">Base</div>${imgTag(result.baseImagePath, 'Base')}</div>\n`;
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">PR</div>${imgTag(result.prImagePath, 'PR')}</div>\n`;
|
||||||
|
html += `<div class="grid-cell"><div class="grid-label">Diff</div>${imgTag(result.diffImagePath, 'Diff')}</div>\n`;
|
||||||
|
html += '</div>\n</div>\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unchanged.length > 0) {
|
||||||
|
html += '<div class="unchanged-section">\n<details>\n<summary>Unchanged pages</summary>\n<ul>\n';
|
||||||
|
for (const result of unchanged) {
|
||||||
|
html += `<li>${result.name}</li>\n`;
|
||||||
|
}
|
||||||
|
html += '</ul>\n</details>\n</div>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>\n</body>\n</html>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI usage
|
||||||
|
if (process.argv[1]?.endsWith('compare.ts') || process.argv[1]?.endsWith('compare.js')) {
|
||||||
|
const [baseDir, prDir, outputDir] = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (!baseDir || !prDir || !outputDir) {
|
||||||
|
throw new Error('Usage: compare.ts <base-dir> <pr-dir> <output-dir>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedOutputDir = resolve(outputDir);
|
||||||
|
const results = compareScreenshots(resolve(baseDir), resolve(prDir), resolvedOutputDir);
|
||||||
|
|
||||||
|
console.log('\nComparison Results:');
|
||||||
|
console.log('==================');
|
||||||
|
for (const r of results) {
|
||||||
|
const status = r.changePercent > 0.1 ? 'CHANGED' : 'unchanged';
|
||||||
|
console.log(` ${r.name}: ${status} (${r.changePercent.toFixed(1)}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = generateMarkdownReport(results);
|
||||||
|
const reportPath = join(resolvedOutputDir, 'report.md');
|
||||||
|
writeFileSync(reportPath, report);
|
||||||
|
console.log(`\nMarkdown report written to: ${reportPath}`);
|
||||||
|
|
||||||
|
const htmlReport = generateHtmlReport(results);
|
||||||
|
const htmlPath = join(resolvedOutputDir, 'visual-review.html');
|
||||||
|
writeFileSync(htmlPath, htmlReport);
|
||||||
|
console.log(`HTML report written to: ${htmlPath}`);
|
||||||
|
|
||||||
|
const jsonPath = join(resolvedOutputDir, 'results.json');
|
||||||
|
writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||||
|
console.log(`Results JSON written to: ${jsonPath}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Maps URL routes to screenshot scenario keys.
|
||||||
|
*
|
||||||
|
* Routes discovered by the dependency analyzer are matched against this map
|
||||||
|
* to determine which screenshot scenarios to run. Routes not in this map
|
||||||
|
* are skipped (they don't have a scenario defined yet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScenarioDefinition {
|
||||||
|
/** The URL path to navigate to */
|
||||||
|
url: string;
|
||||||
|
/** Human-readable name for the screenshot file */
|
||||||
|
name: string;
|
||||||
|
/** Which mock networks this scenario needs */
|
||||||
|
mocks: ('base' | 'timeline' | 'memory')[];
|
||||||
|
/** Optional: selector to wait for before screenshotting */
|
||||||
|
waitForSelector?: string;
|
||||||
|
/** Optional: time to wait after page load (ms) for animations to settle */
|
||||||
|
settleTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from route paths (as output by analyze-deps) to scenario definitions.
|
||||||
|
* A single route might map to multiple scenarios (e.g., different states).
|
||||||
|
*/
|
||||||
|
export const PAGE_SCENARIOS: Record<string, ScenarioDefinition[]> = {
|
||||||
|
'/photos': [
|
||||||
|
{
|
||||||
|
url: '/photos',
|
||||||
|
name: 'photos-timeline',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '[data-thumbnail-focus-container]',
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/albums': [
|
||||||
|
{
|
||||||
|
url: '/albums',
|
||||||
|
name: 'albums-list',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/explore': [
|
||||||
|
{
|
||||||
|
url: '/explore',
|
||||||
|
name: 'explore',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/favorites': [
|
||||||
|
{
|
||||||
|
url: '/favorites',
|
||||||
|
name: 'favorites',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/archive': [
|
||||||
|
{
|
||||||
|
url: '/archive',
|
||||||
|
name: 'archive',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/trash': [
|
||||||
|
{
|
||||||
|
url: '/trash',
|
||||||
|
name: 'trash',
|
||||||
|
mocks: ['base', 'timeline'],
|
||||||
|
waitForSelector: '#asset-grid',
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/people': [
|
||||||
|
{
|
||||||
|
url: '/people',
|
||||||
|
name: 'people',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/sharing': [
|
||||||
|
{
|
||||||
|
url: '/sharing',
|
||||||
|
name: 'sharing',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/search': [
|
||||||
|
{
|
||||||
|
url: '/search',
|
||||||
|
name: 'search',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/memory': [
|
||||||
|
{
|
||||||
|
url: '/memory',
|
||||||
|
name: 'memory',
|
||||||
|
mocks: ['base', 'memory'],
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/user-settings': [
|
||||||
|
{
|
||||||
|
url: '/user-settings',
|
||||||
|
name: 'user-settings',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/map': [
|
||||||
|
{
|
||||||
|
url: '/map',
|
||||||
|
name: 'map',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin': [
|
||||||
|
{
|
||||||
|
url: '/admin',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin/system-settings': [
|
||||||
|
{
|
||||||
|
url: '/admin/system-settings',
|
||||||
|
name: 'admin-system-settings',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/admin/users': [
|
||||||
|
{
|
||||||
|
url: '/admin/users',
|
||||||
|
name: 'admin-users',
|
||||||
|
mocks: ['base'],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/auth/login': [
|
||||||
|
{
|
||||||
|
url: '/auth/login',
|
||||||
|
name: 'login',
|
||||||
|
mocks: [],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/': [
|
||||||
|
{
|
||||||
|
url: '/',
|
||||||
|
name: 'landing',
|
||||||
|
mocks: [],
|
||||||
|
settleTime: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Given a list of routes from the analyzer, return the matching scenarios. */
|
||||||
|
export function getScenariosForRoutes(routes: string[]): ScenarioDefinition[] {
|
||||||
|
const scenarios: ScenarioDefinition[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const defs = PAGE_SCENARIOS[route];
|
||||||
|
if (defs) {
|
||||||
|
for (const def of defs) {
|
||||||
|
if (!seen.has(def.name)) {
|
||||||
|
seen.add(def.name);
|
||||||
|
scenarios.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scenarios;
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Playwright script to capture screenshots for visual diff scenarios.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx playwright test --config e2e/playwright.screenshot.config.ts
|
||||||
|
*
|
||||||
|
* Environment variables:
|
||||||
|
* SCREENSHOT_SCENARIOS - JSON array of scenario names to run (from page-map.ts)
|
||||||
|
* If not set, runs all scenarios.
|
||||||
|
* SCREENSHOT_OUTPUT_DIR - Directory to save screenshots to. Defaults to e2e/screenshots-output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { MemoryResponseDto } from '@immich/sdk';
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { generateMemoriesFromTimeline } from 'src/ui/generators/memory';
|
||||||
|
import {
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
type TimelineAssetConfig,
|
||||||
|
type TimelineData,
|
||||||
|
} from 'src/ui/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
|
import { setupMemoryMockApiRoutes } from 'src/ui/mock-network/memory-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
|
import { PAGE_SCENARIOS, type ScenarioDefinition } from './page-map';
|
||||||
|
|
||||||
|
const OUTPUT_DIR = process.env.SCREENSHOT_OUTPUT_DIR || resolve(import.meta.dirname, '../../../screenshots-output');
|
||||||
|
const SCENARIO_FILTER: string[] | null = process.env.SCREENSHOT_SCENARIOS
|
||||||
|
? JSON.parse(process.env.SCREENSHOT_SCENARIOS)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Collect scenarios to run
|
||||||
|
const allScenarios: ScenarioDefinition[] = [];
|
||||||
|
for (const defs of Object.values(PAGE_SCENARIOS)) {
|
||||||
|
for (const def of defs) {
|
||||||
|
if (!SCENARIO_FILTER || SCENARIO_FILTER.includes(def.name)) {
|
||||||
|
allScenarios.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a fixed seed so screenshots are deterministic across runs
|
||||||
|
faker.seed(42);
|
||||||
|
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineData: TimelineData;
|
||||||
|
let timelineAssets: TimelineAssetConfig[];
|
||||||
|
let memories: MemoryResponseDto[];
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
timelineData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
|
|
||||||
|
timelineAssets = [];
|
||||||
|
for (const timeBucket of timelineData.buckets.values()) {
|
||||||
|
timelineAssets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
memories = generateMemoriesFromTimeline(
|
||||||
|
timelineAssets,
|
||||||
|
adminUserId,
|
||||||
|
[
|
||||||
|
{ year: 2024, assetCount: 3 },
|
||||||
|
{ year: 2023, assetCount: 2 },
|
||||||
|
],
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of allScenarios) {
|
||||||
|
test(`Screenshot: ${scenario.name}`, async ({ context, page }) => {
|
||||||
|
// Set up mocks based on scenario requirements
|
||||||
|
if (scenario.mocks.includes('base')) {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario.mocks.includes('timeline')) {
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
await setupTimelineMockApiRoutes(
|
||||||
|
context,
|
||||||
|
timelineData,
|
||||||
|
{
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
},
|
||||||
|
testContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario.mocks.includes('memory')) {
|
||||||
|
await setupMemoryMockApiRoutes(context, memories, {
|
||||||
|
memoryDeletions: [],
|
||||||
|
assetRemovals: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the page. Use networkidle so SvelteKit hydrates and API
|
||||||
|
// calls complete, but fall back to domcontentloaded if it times out
|
||||||
|
// (e.g. a persistent connection the catch-all mock didn't cover).
|
||||||
|
try {
|
||||||
|
await page.goto(scenario.url, { waitUntil: 'networkidle', timeout: 15_000 });
|
||||||
|
} catch {
|
||||||
|
console.warn(`networkidle timed out for ${scenario.name}, falling back to current state`);
|
||||||
|
// Page has already navigated, just continue with what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for specific selector if specified
|
||||||
|
if (scenario.waitForSelector) {
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(scenario.waitForSelector, { timeout: 15_000 });
|
||||||
|
} catch {
|
||||||
|
console.warn(`Selector ${scenario.waitForSelector} not found for ${scenario.name}, continuing...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for loading spinners to disappear
|
||||||
|
await page
|
||||||
|
.waitForFunction(() => document.querySelectorAll('[data-testid="loading-spinner"]').length === 0, {
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Wait for animations/transitions to settle
|
||||||
|
await page.waitForTimeout(scenario.settleTime ?? 500);
|
||||||
|
|
||||||
|
// Take the screenshot
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(OUTPUT_DIR, `${scenario.name}.png`),
|
||||||
|
fullPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,27 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
|||||||
path: '/',
|
path: '/',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Block socket.io connections — these are persistent WebSocket connections
|
||||||
|
// that prevent networkidle from resolving since there's no real server.
|
||||||
|
await context.route('**/api/socket.io**', async (route) => {
|
||||||
|
return route.abort('connectionrefused');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-all for any /api/ endpoint not explicitly mocked below.
|
||||||
|
// Registered FIRST so specific routes (registered after) take priority
|
||||||
|
// (Playwright checks routes in reverse registration order).
|
||||||
|
// Without this, unmocked API calls hit the static preview server which
|
||||||
|
// either hangs or returns HTML, preventing networkidle and causing timeouts.
|
||||||
|
await context.route('**/api/**', async (route) => {
|
||||||
|
const method = route.request().method();
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: method === 'GET' ? [] : {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.route('**/api/users/me', async (route) => {
|
await context.route('**/api/users/me', async (route) => {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -12,8 +11,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
@@ -131,16 +129,11 @@ class _BottomPanelState extends State<_BottomPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final db = Drift();
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
await db.reset();
|
||||||
for (final suffix in ['', '-wal', '-shm']) {
|
} finally {
|
||||||
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
|
await db.close();
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
|
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
|
||||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
|
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
|
||||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
|
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
|
||||||
*AssetsApi* | [**viewAssetTile**](doc//AssetsApi.md#viewassettile) | **GET** /assets/{id}/tiles/{level}/{col}/{row} | View an asset tile
|
|
||||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
|
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
|
||||||
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
|
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
|
||||||
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
|
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
|
||||||
|
|||||||
@@ -1836,91 +1836,4 @@ class AssetsApi {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// View an asset tile
|
|
||||||
///
|
|
||||||
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
|
|
||||||
///
|
|
||||||
/// Note: This method returns the HTTP [Response].
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [num] col (required):
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [num] level (required):
|
|
||||||
///
|
|
||||||
/// * [num] row (required):
|
|
||||||
///
|
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<Response> viewAssetTileWithHttpInfo(num col, String id, num level, num row, { String? key, String? slug, }) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/assets/{id}/tiles/{level}/{col}/{row}'
|
|
||||||
.replaceAll('{col}', col.toString())
|
|
||||||
.replaceAll('{id}', id)
|
|
||||||
.replaceAll('{level}', level.toString())
|
|
||||||
.replaceAll('{row}', row.toString());
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
if (key != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'key', key));
|
|
||||||
}
|
|
||||||
if (slug != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// View an asset tile
|
|
||||||
///
|
|
||||||
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [num] col (required):
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [num] level (required):
|
|
||||||
///
|
|
||||||
/// * [num] row (required):
|
|
||||||
///
|
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<MultipartFile?> viewAssetTile(num col, String id, num level, num row, { String? key, String? slug, }) async {
|
|
||||||
final response = await viewAssetTileWithHttpInfo(col, id, level, row, key: key, slug: slug, );
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
|
||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
|
||||||
// FormatException when trying to decode an empty string.
|
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
|
||||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4397,103 +4397,6 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/assets/{id}/tiles/{level}/{col}/{row}": {
|
|
||||||
"get": {
|
|
||||||
"description": "Download a specific tile from an image at the specified level - must currently be 0 - and position",
|
|
||||||
"operationId": "viewAssetTile",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "col",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "key",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "level",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "row",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "slug",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/octet-stream": {
|
|
||||||
"schema": {
|
|
||||||
"format": "binary",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "View an asset tile",
|
|
||||||
"tags": [
|
|
||||||
"Assets"
|
|
||||||
],
|
|
||||||
"x-immich-history": [
|
|
||||||
{
|
|
||||||
"version": "v2.7.0",
|
|
||||||
"state": "Added"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "v2.7.0",
|
|
||||||
"state": "Stable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-immich-permission": "asset.view",
|
|
||||||
"x-immich-state": "Stable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/assets/{id}/video/playback": {
|
"/assets/{id}/video/playback": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
||||||
|
|||||||
@@ -4313,27 +4313,6 @@ export function viewAsset({ edited, id, key, size, slug }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* View an asset tile
|
|
||||||
*/
|
|
||||||
export function viewAssetTile({ col, id, key, level, row, slug }: {
|
|
||||||
col: number;
|
|
||||||
id: string;
|
|
||||||
key?: string;
|
|
||||||
level: number;
|
|
||||||
row: number;
|
|
||||||
slug?: string;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
|
||||||
status: 200;
|
|
||||||
data: Blob;
|
|
||||||
}>(`/assets/${encodeURIComponent(id)}/tiles/${encodeURIComponent(level)}/${encodeURIComponent(col)}/${encodeURIComponent(row)}${QS.query(QS.explode({
|
|
||||||
key,
|
|
||||||
slug
|
|
||||||
}))}`, {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Play asset video
|
* Play asset video
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -55,13 +55,6 @@ export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
|||||||
export const getAssetPlaybackPath = (id: string) =>
|
export const getAssetPlaybackPath = (id: string) =>
|
||||||
`/assets/${id}/video/playback`;
|
`/assets/${id}/video/playback`;
|
||||||
|
|
||||||
export const getAssetTilePath = (
|
|
||||||
id: string,
|
|
||||||
level: number,
|
|
||||||
col: number,
|
|
||||||
row: number
|
|
||||||
) => `/assets/${id}/tiles/${level}/${col}/${row}`;
|
|
||||||
|
|
||||||
export const getUserProfileImagePath = (userId: string) =>
|
export const getUserProfileImagePath = (userId: string) =>
|
||||||
`/users/${userId}/profile-image`;
|
`/users/${userId}/profile-image`;
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ importers:
|
|||||||
version: 24.11.0
|
version: 24.11.0
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
byte-size:
|
byte-size:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
@@ -115,10 +115,10 @@ importers:
|
|||||||
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vitest-fetch-mock:
|
vitest-fetch-mock:
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.8.2
|
version: 2.8.2
|
||||||
@@ -276,6 +276,9 @@ importers:
|
|||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.21.0
|
||||||
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -673,7 +676,7 @@ importers:
|
|||||||
version: 13.15.10
|
version: 13.15.10
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.2(jiti@2.6.1)
|
version: 10.0.2(jiti@2.6.1)
|
||||||
@@ -730,7 +733,7 @@ importers:
|
|||||||
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
web:
|
web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -755,9 +758,6 @@ importers:
|
|||||||
'@photo-sphere-viewer/core':
|
'@photo-sphere-viewer/core':
|
||||||
specifier: ^5.14.0
|
specifier: ^5.14.0
|
||||||
version: 5.14.1
|
version: 5.14.1
|
||||||
'@photo-sphere-viewer/equirectangular-tiles-adapter':
|
|
||||||
specifier: ^5.14.1
|
|
||||||
version: 5.14.1(@photo-sphere-viewer/core@5.14.1)
|
|
||||||
'@photo-sphere-viewer/equirectangular-video-adapter':
|
'@photo-sphere-viewer/equirectangular-video-adapter':
|
||||||
specifier: ^5.14.0
|
specifier: ^5.14.0
|
||||||
version: 5.14.1(@photo-sphere-viewer/core@5.14.1)(@photo-sphere-viewer/video-plugin@5.14.1(@photo-sphere-viewer/core@5.14.1))
|
version: 5.14.1(@photo-sphere-viewer/core@5.14.1)(@photo-sphere-viewer/video-plugin@5.14.1(@photo-sphere-viewer/core@5.14.1))
|
||||||
@@ -787,7 +787,7 @@ importers:
|
|||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
fabric:
|
fabric:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.0(encoding@0.1.13)
|
version: 7.2.0
|
||||||
geo-coordinates-parser:
|
geo-coordinates-parser:
|
||||||
specifier: ^1.7.4
|
specifier: ^1.7.4
|
||||||
version: 1.7.4
|
version: 1.7.4
|
||||||
@@ -887,7 +887,7 @@ importers:
|
|||||||
version: 6.9.1
|
version: 6.9.1
|
||||||
'@testing-library/svelte':
|
'@testing-library/svelte':
|
||||||
specifier: ^5.2.8
|
specifier: ^5.2.8
|
||||||
version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
'@testing-library/user-event':
|
'@testing-library/user-event':
|
||||||
specifier: ^14.5.2
|
specifier: ^14.5.2
|
||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
@@ -911,7 +911,7 @@ importers:
|
|||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.0.0
|
specifier: ^17.0.0
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
@@ -974,7 +974,7 @@ importers:
|
|||||||
version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -3889,11 +3889,6 @@ packages:
|
|||||||
'@photo-sphere-viewer/core@5.14.1':
|
'@photo-sphere-viewer/core@5.14.1':
|
||||||
resolution: {integrity: sha512-qrwUudrX9YZms4c2shlY/H3jUP0oh9FyGEqIDr/95ulNZgKbhQ6C/i8zDQ4j8ooFR4+z5FDORQtGvLgPyX8VCA==}
|
resolution: {integrity: sha512-qrwUudrX9YZms4c2shlY/H3jUP0oh9FyGEqIDr/95ulNZgKbhQ6C/i8zDQ4j8ooFR4+z5FDORQtGvLgPyX8VCA==}
|
||||||
|
|
||||||
'@photo-sphere-viewer/equirectangular-tiles-adapter@5.14.1':
|
|
||||||
resolution: {integrity: sha512-QHd9y5cIFXAAZInbKbh+nUz5uzSRTiR8HYApm+ONlDu8JHAp410xNhB1vNm2Q1mVgg8IigQpD9Za5mPq10fESA==}
|
|
||||||
peerDependencies:
|
|
||||||
'@photo-sphere-viewer/core': 5.14.1
|
|
||||||
|
|
||||||
'@photo-sphere-viewer/equirectangular-video-adapter@5.14.1':
|
'@photo-sphere-viewer/equirectangular-video-adapter@5.14.1':
|
||||||
resolution: {integrity: sha512-rZ6igEy1TEfgHB8Ak/8N0rZNYQLbNEGLVmhwNxDMWESCJ9nrNx3tJHFn7k6eZYjj9zJA73xF5YdY6XWUCpZDzg==}
|
resolution: {integrity: sha512-rZ6igEy1TEfgHB8Ak/8N0rZNYQLbNEGLVmhwNxDMWESCJ9nrNx3tJHFn7k6eZYjj9zJA73xF5YdY6XWUCpZDzg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -15198,6 +15193,22 @@ snapshots:
|
|||||||
|
|
||||||
'@mapbox/mapbox-gl-rtl-text@0.3.0': {}
|
'@mapbox/mapbox-gl-rtl-text@0.3.0': {}
|
||||||
|
|
||||||
|
'@mapbox/node-pre-gyp@1.0.11':
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
https-proxy-agent: 5.0.1
|
||||||
|
make-dir: 3.1.0
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
nopt: 5.0.0
|
||||||
|
npmlog: 5.0.1
|
||||||
|
rimraf: 3.0.2
|
||||||
|
semver: 7.7.4
|
||||||
|
tar: 6.2.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
- supports-color
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
|
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
@@ -15873,10 +15884,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
three: 0.179.1
|
three: 0.179.1
|
||||||
|
|
||||||
'@photo-sphere-viewer/equirectangular-tiles-adapter@5.14.1(@photo-sphere-viewer/core@5.14.1)':
|
|
||||||
dependencies:
|
|
||||||
'@photo-sphere-viewer/core': 5.14.1
|
|
||||||
|
|
||||||
'@photo-sphere-viewer/equirectangular-video-adapter@5.14.1(@photo-sphere-viewer/core@5.14.1)(@photo-sphere-viewer/video-plugin@5.14.1(@photo-sphere-viewer/core@5.14.1))':
|
'@photo-sphere-viewer/equirectangular-video-adapter@5.14.1(@photo-sphere-viewer/core@5.14.1)(@photo-sphere-viewer/video-plugin@5.14.1(@photo-sphere-viewer/core@5.14.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@photo-sphere-viewer/core': 5.14.1
|
'@photo-sphere-viewer/core': 5.14.1
|
||||||
@@ -16512,14 +16519,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
svelte: 5.53.5
|
svelte: 5.53.5
|
||||||
|
|
||||||
'@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
'@testing-library/svelte@5.3.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@testing-library/dom': 10.4.1
|
'@testing-library/dom': 10.4.1
|
||||||
'@testing-library/svelte-core': 1.0.0(svelte@5.53.5)
|
'@testing-library/svelte-core': 1.0.0(svelte@5.53.5)
|
||||||
svelte: 5.53.5
|
svelte: 5.53.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17213,7 +17220,7 @@ snapshots:
|
|||||||
|
|
||||||
'@vercel/oidc@3.0.5': {}
|
'@vercel/oidc@3.0.5': {}
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
@@ -17228,11 +17235,11 @@ snapshots:
|
|||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
@@ -17247,7 +17254,7 @@ snapshots:
|
|||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -17974,6 +17981,16 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001774: {}
|
caniuse-lite@1.0.30001774: {}
|
||||||
|
|
||||||
|
canvas@2.11.2:
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/node-pre-gyp': 1.0.11
|
||||||
|
nan: 2.25.0
|
||||||
|
simple-get: 3.1.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
- supports-color
|
||||||
|
optional: true
|
||||||
|
|
||||||
canvas@2.11.2(encoding@0.1.13):
|
canvas@2.11.2(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
|
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
|
||||||
@@ -19571,10 +19588,10 @@ snapshots:
|
|||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
fabric@7.2.0(encoding@0.1.13):
|
fabric@7.2.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
canvas: 2.11.2(encoding@0.1.13)
|
canvas: 2.11.2
|
||||||
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
|
jsdom: 26.1.0(canvas@2.11.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- encoding
|
- encoding
|
||||||
@@ -20753,6 +20770,36 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
jsdom@26.1.0(canvas@2.11.2):
|
||||||
|
dependencies:
|
||||||
|
cssstyle: 4.6.0
|
||||||
|
data-urls: 5.0.0
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
html-encoding-sniffer: 4.0.0
|
||||||
|
http-proxy-agent: 7.0.2
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
is-potential-custom-element-name: 1.0.1
|
||||||
|
nwsapi: 2.2.23
|
||||||
|
parse5: 7.3.0
|
||||||
|
rrweb-cssom: 0.8.0
|
||||||
|
saxes: 6.0.0
|
||||||
|
symbol-tree: 3.2.4
|
||||||
|
tough-cookie: 5.1.2
|
||||||
|
w3c-xmlserializer: 5.0.0
|
||||||
|
webidl-conversions: 7.0.0
|
||||||
|
whatwg-encoding: 3.1.1
|
||||||
|
whatwg-mimetype: 4.0.0
|
||||||
|
whatwg-url: 14.2.0
|
||||||
|
ws: 8.19.0
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
optionalDependencies:
|
||||||
|
canvas: 2.11.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
optional: true
|
||||||
|
|
||||||
jsep@1.4.0: {}
|
jsep@1.4.0: {}
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
@@ -21998,6 +22045,11 @@ snapshots:
|
|||||||
emojilib: 2.4.0
|
emojilib: 2.4.0
|
||||||
skin-tone: 2.0.0
|
skin-tone: 2.0.0
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
node-fetch@2.7.0(encoding@0.1.13):
|
node-fetch@2.7.0(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
@@ -25122,9 +25174,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -25170,7 +25222,51 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/expect': 3.2.4
|
||||||
|
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
'@vitest/runner': 3.2.4
|
||||||
|
'@vitest/snapshot': 3.2.4
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.3
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/debug': 4.1.12
|
||||||
|
'@types/node': 24.11.0
|
||||||
|
happy-dom: 20.6.3
|
||||||
|
jsdom: 26.1.0(canvas@2.11.2)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
|
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.3.0)(happy-dom@20.6.3)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
@@ -25199,7 +25295,7 @@ snapshots:
|
|||||||
'@types/debug': 4.1.12
|
'@types/debug': 4.1.12
|
||||||
'@types/node': 25.3.0
|
'@types/node': 25.3.0
|
||||||
happy-dom: 20.6.3
|
happy-dom: 20.6.3
|
||||||
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
|
jsdom: 26.1.0(canvas@2.11.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
|||||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||||
|
|
||||||
export const FACE_THUMBNAIL_SIZE = 250;
|
export const FACE_THUMBNAIL_SIZE = 250;
|
||||||
export const TILE_TARGET_SIZE = 1024;
|
|
||||||
|
|
||||||
type ModelInfo = { dimSize: number };
|
type ModelInfo = { dimSize: number };
|
||||||
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Next,
|
Next,
|
||||||
Param,
|
Param,
|
||||||
ParseFilePipe,
|
ParseFilePipe,
|
||||||
ParseIntPipe,
|
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@@ -186,29 +185,6 @@ export class AssetMediaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/tiles/:level/:col/:row')
|
|
||||||
@FileResponse()
|
|
||||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
|
||||||
@Endpoint({
|
|
||||||
summary: 'View an asset tile',
|
|
||||||
description: 'Download a specific tile from an image at the specified level - must currently be 0 - and position',
|
|
||||||
history: new HistoryBuilder().added('v2.7.0').stable('v2.7.0'),
|
|
||||||
})
|
|
||||||
async viewAssetTile(
|
|
||||||
@Auth() auth: AuthDto,
|
|
||||||
@Param() { id }: UUIDParamDto,
|
|
||||||
@Param('level', ParseIntPipe) level: number,
|
|
||||||
@Param('col', ParseIntPipe) col: number,
|
|
||||||
@Param('row', ParseIntPipe) row: number,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Next() next: NextFunction,
|
|
||||||
) {
|
|
||||||
if (level !== 0) {
|
|
||||||
throw new Error(`Invalid level ${level}`);
|
|
||||||
}
|
|
||||||
await sendFile(res, next, () => this.service.viewAssetTile(auth, id, level, col, row), this.logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/video/playback')
|
@Get(':id/video/playback')
|
||||||
@FileResponse()
|
@FileResponse()
|
||||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ export class StorageCore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getTilesFolder(asset: ThumbnailPathEntity) {
|
|
||||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}_tiles`);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||||
}
|
}
|
||||||
@@ -157,16 +153,6 @@ export class StorageCore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveAssetTiles(asset: StorageAsset) {
|
|
||||||
const oldDir = getAssetFile(asset.files, AssetFileType.Tiles, { isEdited: false });
|
|
||||||
return this.moveFile({
|
|
||||||
entityId: asset.id,
|
|
||||||
pathType: AssetPathType.Tiles,
|
|
||||||
oldPath: oldDir?.path || null,
|
|
||||||
newPath: StorageCore.getTilesFolder(asset),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveAssetVideo(asset: StorageAsset) {
|
async moveAssetVideo(asset: StorageAsset) {
|
||||||
return this.moveFile({
|
return this.moveFile({
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ export enum AssetFileType {
|
|||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
Sidecar = 'sidecar',
|
Sidecar = 'sidecar',
|
||||||
/** Folder structure containing tiles of the image */
|
|
||||||
Tiles = 'tiles',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
@@ -373,8 +371,6 @@ export enum ManualJobName {
|
|||||||
|
|
||||||
export enum AssetPathType {
|
export enum AssetPathType {
|
||||||
Original = 'original',
|
Original = 'original',
|
||||||
/** Folder structure containing tiles of the image */
|
|
||||||
Tiles = 'tiles',
|
|
||||||
EncodedVideo = 'encoded_video',
|
EncodedVideo = 'encoded_video',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1128,19 +1128,6 @@ export class AssetRepository {
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
|
||||||
async getForTiles(id: string) {
|
|
||||||
// TODO: we don't actually need original path and file name. Plain 'select asset_file.path from asset_file where type = tiles and assetId = id;'?
|
|
||||||
return this.db
|
|
||||||
.selectFrom('asset')
|
|
||||||
.where('asset.id', '=', id)
|
|
||||||
.leftJoin('asset_file', (join) =>
|
|
||||||
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.Tiles),
|
|
||||||
)
|
|
||||||
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async getForVideo(id: string) {
|
async getForVideo(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
|
|||||||
@@ -182,24 +182,6 @@ export class MediaRepository {
|
|||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files/0' directory containing tiles
|
|
||||||
*/
|
|
||||||
async generateTiles(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
|
||||||
// size is intended tile size, don't resize input image.
|
|
||||||
const pipeline = await this.getImageDecodingPipeline(input, { ...options, size: undefined });
|
|
||||||
await pipeline
|
|
||||||
.toFormat(options.format) // TODO: set quality and chroma ss?
|
|
||||||
.tile({
|
|
||||||
depth: 'one',
|
|
||||||
size: options.size,
|
|
||||||
})
|
|
||||||
.toFile(output);
|
|
||||||
// TODO: move <uuid>_tiles_files/0 dir to <uuid>_tiles
|
|
||||||
// TODO: delete <uuid>_tiles_files/vips-properties.xml
|
|
||||||
// TODO: delete <uuid>_tiles.dzi
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
let pipeline = sharp(input, {
|
let pipeline = sharp(input, {
|
||||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||||
|
|||||||
@@ -720,38 +720,6 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAssetTile', () => {
|
|
||||||
it('should require asset.view permissions', async () => {
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, 'id', 0, 0, 0)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
|
||||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
|
||||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw an error if the asset tiles dir could not be found', async () => {
|
|
||||||
const asset = AssetFactory.create();
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
||||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: null });
|
|
||||||
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get tile file', async () => {
|
|
||||||
const asset = AssetFactory.from().file({ type: AssetFileType.Tiles, path: '/path/to/asset_tiles' }).build();
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
||||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: asset.files[0].path });
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).resolves.toEqual(
|
|
||||||
new ImmichFileResponse({
|
|
||||||
path: `${asset.files[0].path}_files/0/0_0.jpeg`,
|
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.asset.getForTiles).toHaveBeenCalledWith(asset.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('playbackVideo', () => {
|
describe('playbackVideo', () => {
|
||||||
it('should require asset.view permissions', async () => {
|
it('should require asset.view permissions', async () => {
|
||||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|||||||
@@ -262,26 +262,6 @@ export class AssetMediaService extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async viewAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise<ImmichFileResponse> {
|
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
|
||||||
|
|
||||||
// TODO: get tile info { width, height } and check against col, row to return NotFound instead of 500 when tile can't be found in sendFile.
|
|
||||||
const { path } = await this.assetRepository.getForTiles(id);
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
throw new NotFoundException('Asset tiles not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// By definition of the tiles format, it's always .jpeg; should ImageFormat.Jpeg be used?
|
|
||||||
const tilePath = `${path}_files/${level}/${col}_${row}.jpeg`;
|
|
||||||
|
|
||||||
return new ImmichFileResponse({
|
|
||||||
path: tilePath,
|
|
||||||
contentType: mimeTypes.lookup(tilePath),
|
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||||
|
|
||||||
|
|||||||
@@ -1246,7 +1246,8 @@ describe(MediaService.name, () => {
|
|||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledExactlyOnceWith('XMP-GPano', asset.originalPath, expect.any(String));
|
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect encoding options when generating full-size preview', async () => {
|
it('should respect encoding options when generating full-size preview', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE, TILE_TARGET_SIZE } from 'src/constants';
|
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||||
import { AssetFile, Exif } from 'src/database';
|
import { AssetFile, Exif } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
DecodeToBufferOptions,
|
DecodeToBufferOptions,
|
||||||
GenerateThumbnailOptions,
|
GenerateThumbnailOptions,
|
||||||
ImageDimensions,
|
ImageDimensions,
|
||||||
ImageOptions,
|
|
||||||
JobItem,
|
JobItem,
|
||||||
JobOf,
|
JobOf,
|
||||||
VideoFormat,
|
VideoFormat,
|
||||||
@@ -164,7 +163,6 @@ export class MediaService extends BaseService {
|
|||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
||||||
await this.storageCore.moveAssetTiles(asset);
|
|
||||||
await this.storageCore.moveAssetVideo(asset);
|
await this.storageCore.moveAssetVideo(asset);
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
@@ -278,8 +276,8 @@ export class MediaService extends BaseService {
|
|||||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||||
const generateFullsize =
|
const generateFullsize =
|
||||||
(image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') &&
|
||||||
asset.exifInfo.projectionType === 'EQUIRECTANGULAR' ||
|
!mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
||||||
useEdits;
|
useEdits;
|
||||||
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
||||||
|
|
||||||
@@ -385,60 +383,23 @@ export class MediaService extends BaseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: probably extract to helper method
|
|
||||||
// TODO: handle cropped panoramas. Tile as normal but save some offset?
|
|
||||||
let tileInfo: UpsertFileOptions | undefined;
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
|
||||||
// TODO: get uncropped width from asset (FullPanoWidthPixels if present). -> TODO find out why i wrote this down as a todo
|
|
||||||
const originalSize = asset.exifInfo.exifImageWidth!;
|
|
||||||
|
|
||||||
// Get the number of tiles at the exact target size, rounded up (to at least 1 tile).
|
|
||||||
const numTilesExact = Math.ceil(originalSize / TILE_TARGET_SIZE);
|
|
||||||
// Then round up to the nearest power of 2 (photo-sphere-viewer requirement).
|
|
||||||
const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact)));
|
|
||||||
const tileSize = Math.ceil(originalSize / numTiles);
|
|
||||||
|
|
||||||
tileInfo = {
|
|
||||||
assetId: asset.id,
|
|
||||||
type: AssetFileType.Tiles,
|
|
||||||
path: StorageCore.getTilesFolder(asset),
|
|
||||||
isEdited: false,
|
|
||||||
isProgressive: false,
|
|
||||||
isTransparent: false,
|
|
||||||
};
|
|
||||||
const tilesOptions = {
|
|
||||||
...baseOptions,
|
|
||||||
quality: image.preview.quality,
|
|
||||||
format: ImageFormat.Jpeg,
|
|
||||||
size: tileSize,
|
|
||||||
};
|
|
||||||
promises.push(this.mediaRepository.generateTiles(data, tilesOptions, tileInfo.path));
|
|
||||||
|
|
||||||
console.log('Tile info for DB:', {
|
|
||||||
width: originalSize,
|
|
||||||
cols: numTiles,
|
|
||||||
rows: numTiles / 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputs = await Promise.all(promises);
|
const outputs = await Promise.all(promises);
|
||||||
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||||
await this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path);
|
const promises = [
|
||||||
|
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
|
||||||
|
fullsizeFile
|
||||||
|
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
|
||||||
|
: Promise.resolve(),
|
||||||
|
];
|
||||||
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedDimensions = { width: info.width, height: info.height };
|
const decodedDimensions = { width: info.width, height: info.height };
|
||||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||||
const files = [previewFile, thumbnailFile];
|
|
||||||
if (fullsizeFile) {
|
|
||||||
files.push(fullsizeFile);
|
|
||||||
}
|
|
||||||
if (tileInfo) {
|
|
||||||
files.push(tileInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files,
|
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||||
thumbhash: outputs[0] as Buffer,
|
thumbhash: outputs[0] as Buffer,
|
||||||
fullsizeDimensions,
|
fullsizeDimensions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
||||||
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
||||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
||||||
tilesPath: getAssetFile(files, AssetFileType.Tiles, { isEdited: false }),
|
|
||||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
||||||
|
|
||||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||||||
getForOriginal: vitest.fn(),
|
getForOriginal: vitest.fn(),
|
||||||
getForOriginals: vitest.fn(),
|
getForOriginals: vitest.fn(),
|
||||||
getForThumbnail: vitest.fn(),
|
getForThumbnail: vitest.fn(),
|
||||||
getForTiles: vitest.fn(),
|
|
||||||
getForVideo: vitest.fn(),
|
getForVideo: vitest.fn(),
|
||||||
getForEdit: vitest.fn(),
|
getForEdit: vitest.fn(),
|
||||||
getForOcr: vitest.fn(),
|
getForOcr: vitest.fn(),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
|
|||||||
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
||||||
return {
|
return {
|
||||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
generateTiles: vitest.fn().mockImplementation(() => Promise.resolve()),
|
|
||||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.14.0",
|
"@photo-sphere-viewer/core": "^5.14.0",
|
||||||
"@photo-sphere-viewer/equirectangular-tiles-adapter": "^5.14.1",
|
|
||||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
|
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
|
||||||
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
||||||
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -11,38 +11,85 @@
|
|||||||
|
|
||||||
let { options }: Props = $props();
|
let { options }: Props = $props();
|
||||||
|
|
||||||
const getExampleDate = () => DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale });
|
const getLuxonExample = (format: string) => {
|
||||||
|
return DateTime.fromISO('2022-02-15T20:03:05.250Z', { locale: $locale }).toFormat(format);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet example(title: string, options: Array<string>)}
|
|
||||||
<div>
|
|
||||||
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{title}</Text>
|
|
||||||
<ul>
|
|
||||||
{#each options as format, index (index)}
|
|
||||||
<li>{`{{${format}}} - ${getExampleDate().toFormat(format)}`}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Text size="small">{$t('date_and_time')}</Text>
|
<Text size="small">{$t('date_and_time')}</Text>
|
||||||
|
|
||||||
|
<!-- eslint-disable svelte/no-useless-mustaches -->
|
||||||
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
<Card class="mt-2 text-sm bg-light-50 shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
|
||||||
<Text color="primary"
|
<Text color="primary"
|
||||||
>{$t('admin.storage_template_date_time_sample', { values: { date: getExampleDate().toISO() } })}</Text
|
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}</Text
|
||||||
>
|
>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
||||||
{@render example($t('year'), options.yearOptions)}
|
<div>
|
||||||
{@render example($t('month'), options.monthOptions)}
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('year')}</Text>
|
||||||
{@render example($t('week'), options.weekOptions)}
|
<ul>
|
||||||
{@render example($t('day'), options.dayOptions)}
|
{#each options.yearOptions as yearFormat, index (index)}
|
||||||
{@render example($t('hour'), options.hourOptions)}
|
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
|
||||||
{@render example($t('minute'), options.minuteOptions)}
|
{/each}
|
||||||
{@render example($t('second'), options.secondOptions)}
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('month')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.monthOptions as monthFormat, index (index)}
|
||||||
|
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('week')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.weekOptions as weekFormat, index (index)}
|
||||||
|
<li>{'{{'}{weekFormat}{'}}'} - {getLuxonExample(weekFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('day')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.dayOptions as dayFormat, index (index)}
|
||||||
|
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('hour')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.hourOptions as dayFormat, index (index)}
|
||||||
|
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('minute')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.minuteOptions as dayFormat, index (index)}
|
||||||
|
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text fontWeight="medium" size="tiny" color="primary" class="mb-1">{$t('second')}</Text>
|
||||||
|
<ul>
|
||||||
|
{#each options.secondOptions as dayFormat, index (index)}
|
||||||
|
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { getAssetTileUrl, getAssetUrl } from '$lib/utils';
|
import { getAssetUrl } from '$lib/utils';
|
||||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -14,39 +14,17 @@
|
|||||||
|
|
||||||
const assetId = $derived(asset.id);
|
const assetId = $derived(asset.id);
|
||||||
|
|
||||||
// TODO: get this via asset.tiles or whatever through the API
|
|
||||||
const tileconfig = $derived.by(() => {
|
|
||||||
// Get the number of tiles at the exact target size, rounded up (to at least 1 tile).
|
|
||||||
const numTilesExact = Math.ceil(asset.exifInfo?.exifImageWidth! / 1024);
|
|
||||||
// Then round up to the nearest power of 2 (photo-sphere-viewer requirement).
|
|
||||||
const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact)));
|
|
||||||
return {
|
|
||||||
width: asset.exifInfo?.exifImageWidth!,
|
|
||||||
cols: numTiles,
|
|
||||||
rows: numTiles / 2,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadAssetData = async (id: string) => {
|
const loadAssetData = async (id: string) => {
|
||||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||||
return URL.createObjectURL(data);
|
return URL.createObjectURL(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: determine whether to return null based on 1. if asset has tiles, 2. if tile is inside 'cropped' bounds.
|
|
||||||
const tileUrl = (col: number, row: number, level: number) =>
|
|
||||||
tileconfig ? getAssetTileUrl({ id: asset.id, level, col, row, cacheKey: asset.thumbhash }) : null;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||||
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [data, { default: PhotoSphereViewer }]}
|
{:then [data, { default: PhotoSphereViewer }]}
|
||||||
<PhotoSphereViewer
|
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||||
baseUrl={data}
|
|
||||||
{tileUrl}
|
|
||||||
{tileconfig}
|
|
||||||
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
|
||||||
/>
|
|
||||||
{:catch}
|
{:catch}
|
||||||
{$t('errors.failed_to_load_asset')}
|
{$t('errors.failed_to_load_asset')}
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
type PluginConstructor,
|
type PluginConstructor,
|
||||||
} from '@photo-sphere-viewer/core';
|
} from '@photo-sphere-viewer/core';
|
||||||
import '@photo-sphere-viewer/core/index.css';
|
import '@photo-sphere-viewer/core/index.css';
|
||||||
import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';
|
|
||||||
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
||||||
import '@photo-sphere-viewer/markers-plugin/index.css';
|
import '@photo-sphere-viewer/markers-plugin/index.css';
|
||||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||||
@@ -30,14 +29,6 @@
|
|||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHARED_VIEWER_CONFIG = {
|
|
||||||
touchmoveTwoFingers: false,
|
|
||||||
mousewheelCtrlKey: false,
|
|
||||||
minFov: 15,
|
|
||||||
maxFov: 90,
|
|
||||||
zoomSpeed: 0.5,
|
|
||||||
fisheye: false,
|
|
||||||
};
|
|
||||||
// Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3'
|
// Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3'
|
||||||
const OCR_BOX_SVG_STYLE = {
|
const OCR_BOX_SVG_STYLE = {
|
||||||
fill: 'var(--color-blue-500)',
|
fill: 'var(--color-blue-500)',
|
||||||
@@ -49,31 +40,15 @@
|
|||||||
const OCR_TOOLTIP_HTML_CLASS =
|
const OCR_TOOLTIP_HTML_CLASS =
|
||||||
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
|
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
|
||||||
|
|
||||||
type TileConfig = {
|
|
||||||
width: number;
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
baseUrl: string | { source: string };
|
panorama: string | { source: string };
|
||||||
tileUrl?: (col: number, row: number, level: number) => string | null;
|
|
||||||
tileconfig?: TileConfig;
|
|
||||||
originalPanorama?: string | { source: string };
|
originalPanorama?: string | { source: string };
|
||||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||||
navbar?: boolean;
|
navbar?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||||
baseUrl,
|
|
||||||
tileUrl,
|
|
||||||
tileconfig,
|
|
||||||
originalPanorama,
|
|
||||||
adapter = EquirectangularAdapter,
|
|
||||||
plugins = [],
|
|
||||||
navbar = false,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let container: HTMLDivElement | undefined = $state();
|
let container: HTMLDivElement | undefined = $state();
|
||||||
let viewer: Viewer;
|
let viewer: Viewer;
|
||||||
@@ -197,32 +172,20 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tileconfig) {
|
viewer = new Viewer({
|
||||||
viewer = new Viewer({
|
adapter,
|
||||||
adapter: EquirectangularTilesAdapter,
|
plugins: [
|
||||||
panorama: {
|
MarkersPlugin,
|
||||||
...tileconfig,
|
SettingsPlugin,
|
||||||
baseUrl,
|
[
|
||||||
tileUrl,
|
ResolutionPlugin,
|
||||||
},
|
{
|
||||||
plugins: [MarkersPlugin, ...plugins],
|
|
||||||
container,
|
|
||||||
navbar,
|
|
||||||
...SHARED_VIEWER_CONFIG,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
viewer = new Viewer({
|
|
||||||
adapter,
|
|
||||||
plugins: [
|
|
||||||
MarkersPlugin,
|
|
||||||
SettingsPlugin,
|
|
||||||
ResolutionPlugin.withConfig({
|
|
||||||
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
||||||
resolutions: [
|
resolutions: [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
label: 'Default',
|
label: 'Default',
|
||||||
panorama: baseUrl,
|
panorama,
|
||||||
},
|
},
|
||||||
...(originalPanorama
|
...(originalPanorama
|
||||||
? [
|
? [
|
||||||
@@ -234,25 +197,24 @@
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
}),
|
},
|
||||||
...plugins,
|
|
||||||
],
|
],
|
||||||
container,
|
...plugins,
|
||||||
navbar,
|
],
|
||||||
...SHARED_VIEWER_CONFIG,
|
container,
|
||||||
});
|
touchmoveTwoFingers: false,
|
||||||
}
|
mousewheelCtrlKey: false,
|
||||||
|
navbar,
|
||||||
|
minFov: 15,
|
||||||
|
maxFov: 90,
|
||||||
|
zoomSpeed: 0.5,
|
||||||
|
fisheye: false,
|
||||||
|
});
|
||||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||||
// zoomLevel is 0-100
|
// zoomLevel is 0-100
|
||||||
assetViewerManager.zoom = zoomLevel / 50;
|
assetViewerManager.zoom = zoomLevel / 50;
|
||||||
|
|
||||||
if (!resolutionPlugin) {
|
|
||||||
hasChangedResolution = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
||||||
// Replace the preview with the original
|
// Replace the preview with the original
|
||||||
void resolutionPlugin.setResolution('original');
|
void resolutionPlugin.setResolution('original');
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||||
<PhotoSphereViewer
|
<PhotoSphereViewer
|
||||||
baseUrl={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||||
plugins={[videoPlugin]}
|
plugins={[videoPlugin]}
|
||||||
{adapter}
|
{adapter}
|
||||||
|
navbar
|
||||||
/>
|
/>
|
||||||
{:catch}
|
{:catch}
|
||||||
{$t('errors.failed_to_load_asset')}
|
{$t('errors.failed_to_load_asset')}
|
||||||
|
|||||||
@@ -283,7 +283,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||||
<!-- GIF -->
|
<!-- GIF -->
|
||||||
<div class="absolute h-full w-full pointer-events-none">
|
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||||
|
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
class={imageClass}
|
class={imageClass}
|
||||||
{brokenAssetClass}
|
{brokenAssetClass}
|
||||||
@@ -293,6 +294,11 @@
|
|||||||
heightStyle="{height}px"
|
heightStyle="{height}px"
|
||||||
curve={selected}
|
curve={selected}
|
||||||
/>
|
/>
|
||||||
|
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||||
|
<span class="pe-2 pt-2">
|
||||||
|
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -312,7 +318,7 @@
|
|||||||
<!-- icon overlay -->
|
<!-- icon overlay -->
|
||||||
<div class="z-2 absolute inset-0">
|
<div class="z-2 absolute inset-0">
|
||||||
<!-- Gradient overlay on hover -->
|
<!-- Gradient overlay on hover -->
|
||||||
{#if !usingMobileDevice && !disabled && !asset.isVideo}
|
{#if !usingMobileDevice && !disabled}
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
|
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 ',
|
||||||
@@ -348,7 +354,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||||
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||||
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -356,7 +362,7 @@
|
|||||||
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||||
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||||
<span class="pe-2 pt-2">
|
<span class="pe-2 pt-2">
|
||||||
<Icon icon={mdiRotate360} size="24" />
|
<Icon data-icon-equirectangular icon={mdiRotate360} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -364,7 +370,7 @@
|
|||||||
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
||||||
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||||
<span class="pe-2 pt-2">
|
<span class="pe-2 pt-2">
|
||||||
<Icon icon={mouseOver ? mdiMotionPauseOutline : mdiFileGifBox} size="24" />
|
<Icon data-icon-playable icon={mdiFileGifBox} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -379,7 +385,7 @@
|
|||||||
>
|
>
|
||||||
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
||||||
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
||||||
<Icon icon={mdiCameraBurst} size="24" />
|
<Icon data-icon-stack icon={mdiCameraBurst} size="24" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||||
{#if enablePlayback}
|
{#if enablePlayback}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingSpinner size="large" />
|
<LoadingSpinner />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
<Icon icon={mdiAlertCircleOutline} size="24" class="text-red-600" />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import appleSplash20482732 from '$lib/assets/apple/apple-splash-2048-2732.png';
|
||||||
|
import appleSplash27322048 from '$lib/assets/apple/apple-splash-2732-2048.png';
|
||||||
|
import appleSplash16682388 from '$lib/assets/apple/apple-splash-1668-2388.png';
|
||||||
|
import appleSplash23881668 from '$lib/assets/apple/apple-splash-2388-1668.png';
|
||||||
|
import appleSplash15362048 from '$lib/assets/apple/apple-splash-1536-2048.png';
|
||||||
|
import appleSplash20481536 from '$lib/assets/apple/apple-splash-2048-1536.png';
|
||||||
|
import appleSplash16682224 from '$lib/assets/apple/apple-splash-1668-2224.png';
|
||||||
|
import appleSplash22241668 from '$lib/assets/apple/apple-splash-2224-1668.png';
|
||||||
|
import appleSplash16202160 from '$lib/assets/apple/apple-splash-1620-2160.png';
|
||||||
|
import appleSplash21601620 from '$lib/assets/apple/apple-splash-2160-1620.png';
|
||||||
|
import appleSplash12902796 from '$lib/assets/apple/apple-splash-1290-2796.png';
|
||||||
|
import appleSplash27961290 from '$lib/assets/apple/apple-splash-2796-1290.png';
|
||||||
|
import appleSplash11792556 from '$lib/assets/apple/apple-splash-1179-2556.png';
|
||||||
|
import appleSplash25561179 from '$lib/assets/apple/apple-splash-2556-1179.png';
|
||||||
|
import appleSplash12842778 from '$lib/assets/apple/apple-splash-1284-2778.png';
|
||||||
|
import appleSplash27781284 from '$lib/assets/apple/apple-splash-2778-1284.png';
|
||||||
|
import appleSplash11702532 from '$lib/assets/apple/apple-splash-1170-2532.png';
|
||||||
|
import appleSplash25321170 from '$lib/assets/apple/apple-splash-2532-1170.png';
|
||||||
|
import appleSplash11252436 from '$lib/assets/apple/apple-splash-1125-2436.png';
|
||||||
|
import appleSplash24361125 from '$lib/assets/apple/apple-splash-2436-1125.png';
|
||||||
|
import appleSplash12422688 from '$lib/assets/apple/apple-splash-1242-2688.png';
|
||||||
|
import appleSplash26881242 from '$lib/assets/apple/apple-splash-2688-1242.png';
|
||||||
|
import appleSplash8281792 from '$lib/assets/apple/apple-splash-828-1792.png';
|
||||||
|
import appleSplash1792828 from '$lib/assets/apple/apple-splash-1792-828.png';
|
||||||
|
import appleSplash12422208 from '$lib/assets/apple/apple-splash-1242-2208.png';
|
||||||
|
import appleSplash22081242 from '$lib/assets/apple/apple-splash-2208-1242.png';
|
||||||
|
import appleSplash7501334 from '$lib/assets/apple/apple-splash-750-1334.png';
|
||||||
|
import appleSplash1334750 from '$lib/assets/apple/apple-splash-1334-750.png';
|
||||||
|
import appleSplash6401136 from '$lib/assets/apple/apple-splash-640-1136.png';
|
||||||
|
import appleSplash1136640 from '$lib/assets/apple/apple-splash-1136-640.png';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash20482732}
|
||||||
|
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash27322048}
|
||||||
|
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash16682388}
|
||||||
|
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash23881668}
|
||||||
|
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash15362048}
|
||||||
|
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash20481536}
|
||||||
|
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash16682224}
|
||||||
|
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash22241668}
|
||||||
|
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash16202160}
|
||||||
|
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash21601620}
|
||||||
|
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash12902796}
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash27961290}
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash11792556}
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash25561179}
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash12842778}
|
||||||
|
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash27781284}
|
||||||
|
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash11702532}
|
||||||
|
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash25321170}
|
||||||
|
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash11252436}
|
||||||
|
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash24361125}
|
||||||
|
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash12422688}
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash26881242}
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash8281792}
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash1792828}
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash12422208}
|
||||||
|
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash22081242}
|
||||||
|
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash7501334}
|
||||||
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash1334750}
|
||||||
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash6401136}
|
||||||
|
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href={appleSplash1136640}
|
||||||
|
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
@@ -50,7 +50,10 @@
|
|||||||
|
|
||||||
<svelte:window bind:innerWidth />
|
<svelte:window bind:innerWidth />
|
||||||
|
|
||||||
<nav id="dashboard-navbar" class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm">
|
<nav
|
||||||
|
id="dashboard-navbar"
|
||||||
|
class="max-md:h-(--navbar-height-md) h-(--navbar-height) w-dvw text-sm bg-red-50 dark:bg-red-950"
|
||||||
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<div
|
||||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
|
||||||
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
|
||||||
</a>
|
</a>
|
||||||
|
<span class="text-xs font-bold text-red-500 ms-2">[VISUAL TEST]</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
||||||
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
<div class="hidden w-full max-w-5xl flex-1 tall:ps-0 sm:block">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
getAssetOriginalPath,
|
getAssetOriginalPath,
|
||||||
getAssetPlaybackPath,
|
getAssetPlaybackPath,
|
||||||
getAssetThumbnailPath,
|
getAssetThumbnailPath,
|
||||||
getAssetTilePath,
|
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
getPeopleThumbnailPath,
|
getPeopleThumbnailPath,
|
||||||
getUserProfileImagePath,
|
getUserProfileImagePath,
|
||||||
@@ -238,13 +237,6 @@ export const getAssetPlaybackUrl = (options: AssetUrlOptions) => {
|
|||||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
|
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
|
||||||
};
|
};
|
||||||
|
|
||||||
type TileUrlOptions = { id: string, level: number, col: number, row: number, cacheKey?: string | null };
|
|
||||||
|
|
||||||
export const getAssetTileUrl = (options: TileUrlOptions) => {
|
|
||||||
const { id, level, col, row, cacheKey } = options;
|
|
||||||
return createUrl(getAssetTilePath(id, level, col, row), { ...authManager.params, c: cacheKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getProfileImageUrl = (user: UserResponseDto) =>
|
export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||||
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });
|
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed top-0 start-0 w-full z-2">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed top-0 start-0 w-full z-2">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||||
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
import ErrorLayout from '$lib/components/layouts/ErrorLayout.svelte';
|
||||||
|
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
||||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||||
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
|
import VersionAnnouncement from '$lib/components/VersionAnnouncement.svelte';
|
||||||
@@ -188,8 +189,8 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{page.data.meta?.title || 'Web'} - Immich</title>
|
<title>{page.data.meta?.title || 'Web'} - Immich</title>
|
||||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||||
<meta name="theme-color" content="white" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="currentColor" />
|
||||||
<meta name="theme-color" content="black" media="(prefers-color-scheme: dark)" />
|
<AppleHeader />
|
||||||
|
|
||||||
{#if page.data.meta}
|
{#if page.data.meta}
|
||||||
<meta name="description" content={page.data.meta.description} />
|
<meta name="description" content={page.data.meta.description} />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"description": "Self-hosted photo and video backup solution directly from your mobile phone.",
|
"description": "Self-hosted photo and video backup solution directly from your mobile phone.",
|
||||||
"dir": "auto",
|
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -10,6 +9,12 @@
|
|||||||
"src": "manifest-icon-192.maskable.png",
|
"src": "manifest-icon-192.maskable.png",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"purpose": "maskable",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"src": "manifest-icon-192.maskable.png",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"purpose": "any",
|
"purpose": "any",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
@@ -26,19 +31,5 @@
|
|||||||
"lang": "en",
|
"lang": "en",
|
||||||
"name": "Immich",
|
"name": "Immich",
|
||||||
"short_name": "Immich",
|
"short_name": "Immich",
|
||||||
"shortcuts": [
|
|
||||||
{
|
|
||||||
"name": "Photos",
|
|
||||||
"url": "/photos"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Albums",
|
|
||||||
"url": "/albums"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Map",
|
|
||||||
"url": "/map"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"start_url": "/"
|
"start_url": "/"
|
||||||
}
|
}
|
||||||
|
|||||||