mirror of
https://github.com/immich-app/immich.git
synced 2026-05-25 09:02:31 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dc89e2f15 | |||
| e79a98fa82 | |||
| 590a9df7ec | |||
| ed04d87273 |
@@ -1,395 +0,0 @@
|
||||
import { readFileSync, appendFileSync, readdirSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
json: { type: "string" },
|
||||
name: { type: "string" },
|
||||
framework: { type: "string", default: "vitest" },
|
||||
coverage: { type: "string" },
|
||||
"pr-comment": { type: "boolean", default: false },
|
||||
"artifacts-dir": { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds) {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
}
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = (seconds % 60).toFixed(0);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function parseVitestResults(data) {
|
||||
const startTime = data.startTime ?? 0;
|
||||
const endTime = Math.max(
|
||||
...(data.testResults ?? []).map((r) => r.endTime ?? 0),
|
||||
startTime,
|
||||
);
|
||||
|
||||
return {
|
||||
total: data.numTotalTests ?? 0,
|
||||
passed: data.numPassedTests ?? 0,
|
||||
failed: data.numFailedTests ?? 0,
|
||||
skipped: data.numPendingTests ?? 0,
|
||||
flaky: 0,
|
||||
duration: endTime - startTime,
|
||||
success: data.success ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePlaywrightResults(data) {
|
||||
const stats = data.stats ?? {};
|
||||
const passed = stats.expected ?? 0;
|
||||
const failed = stats.unexpected ?? 0;
|
||||
const flaky = stats.flaky ?? 0;
|
||||
const skipped = stats.skipped ?? 0;
|
||||
|
||||
return {
|
||||
total: passed + failed + flaky + skipped,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
flaky,
|
||||
duration: stats.duration ?? 0,
|
||||
success: failed === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCoverageSummary(data) {
|
||||
const total = data.total ?? {};
|
||||
|
||||
const files = [];
|
||||
for (const [filePath, entry] of Object.entries(data)) {
|
||||
if (filePath === "total") {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
file: filePath.replace(/^.*?\/src\//, "src/"),
|
||||
lines: entry.lines?.pct ?? 0,
|
||||
branches: entry.branches?.pct ?? 0,
|
||||
functions: entry.functions?.pct ?? 0,
|
||||
statements: entry.statements?.pct ?? 0,
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => a.lines - b.lines);
|
||||
|
||||
return {
|
||||
lines: total.lines?.pct ?? 0,
|
||||
branches: total.branches?.pct ?? 0,
|
||||
functions: total.functions?.pct ?? 0,
|
||||
statements: total.statements?.pct ?? 0,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMarkdown(name, results, coverage) {
|
||||
const statusIcon =
|
||||
results.failed > 0
|
||||
? "\u274c"
|
||||
: results.flaky > 0
|
||||
? "\u26a0\ufe0f"
|
||||
: "\u2705";
|
||||
const lines = [];
|
||||
|
||||
lines.push(`### ${statusIcon} ${name}`);
|
||||
lines.push("");
|
||||
lines.push("| Metric | Value |");
|
||||
lines.push("|--------|-------|");
|
||||
lines.push(`| Total | ${results.total} |`);
|
||||
lines.push(`| Passed | ${results.passed} |`);
|
||||
lines.push(`| Failed | ${results.failed} |`);
|
||||
lines.push(`| Skipped | ${results.skipped} |`);
|
||||
if (results.flaky > 0) {
|
||||
lines.push(`| Flaky | ${results.flaky} |`);
|
||||
}
|
||||
lines.push(`| Duration | ${formatDuration(results.duration)} |`);
|
||||
lines.push("");
|
||||
|
||||
if (coverage) {
|
||||
lines.push("#### Coverage");
|
||||
lines.push("");
|
||||
lines.push("| Metric | Coverage |");
|
||||
lines.push("|--------|----------|");
|
||||
lines.push(`| Lines | ${coverage.lines}% |`);
|
||||
lines.push(`| Branches | ${coverage.branches}% |`);
|
||||
lines.push(`| Functions | ${coverage.functions}% |`);
|
||||
lines.push(`| Statements | ${coverage.statements}% |`);
|
||||
lines.push("");
|
||||
|
||||
if (coverage.files?.length > 0) {
|
||||
lines.push("<details>");
|
||||
lines.push(
|
||||
`<summary>File coverage (${coverage.files.length} files)</summary>`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| File | Lines | Branches | Functions |");
|
||||
lines.push("|------|-------|----------|-----------|");
|
||||
for (const file of coverage.files) {
|
||||
lines.push(
|
||||
`| ${file.file} | ${file.lines}% | ${file.branches}% | ${file.functions}% |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("</details>");
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const ARTIFACT_CONFIGS = [
|
||||
{
|
||||
pattern: "report-server-unit",
|
||||
name: "Server Unit Tests",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: "coverage/coverage-summary.json",
|
||||
},
|
||||
{
|
||||
pattern: "report-web-unit",
|
||||
name: "Web Unit Tests",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: "coverage/coverage-summary.json",
|
||||
},
|
||||
{
|
||||
pattern: "report-server-medium",
|
||||
name: "Server Medium Tests",
|
||||
framework: "vitest",
|
||||
testFile: "test-results-medium.json",
|
||||
coverageFile: "coverage/coverage-summary.json",
|
||||
},
|
||||
{
|
||||
pattern: "report-cli-unit",
|
||||
name: "CLI Unit Tests",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-cli-unit-win",
|
||||
name: "CLI Unit Tests (Windows)",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-e2e-server-cli-",
|
||||
name: "E2E Server & CLI",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-e2e-server-maintenance-",
|
||||
name: "E2E Server Maintenance",
|
||||
framework: "vitest",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-e2e-web-",
|
||||
name: "E2E Web",
|
||||
framework: "playwright",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-e2e-web-ui-",
|
||||
name: "E2E Web UI",
|
||||
framework: "playwright",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
{
|
||||
pattern: "report-e2e-web-maintenance-",
|
||||
name: "E2E Web Maintenance",
|
||||
framework: "playwright",
|
||||
testFile: "test-results.json",
|
||||
coverageFile: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
function getStatusIcon(results) {
|
||||
if (results.failed > 0) {
|
||||
return "\u274c";
|
||||
}
|
||||
if (results.flaky > 0) {
|
||||
return "\u26a0\ufe0f";
|
||||
}
|
||||
return "\u2705";
|
||||
}
|
||||
|
||||
function discoverArtifacts(artifactsDir) {
|
||||
const dirs = readdirSync(artifactsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
const suites = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const config = ARTIFACT_CONFIGS.find((c) => dir.startsWith(c.pattern));
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = dir.slice(config.pattern.length);
|
||||
const displayName = suffix ? `${config.name} (${suffix})` : config.name;
|
||||
|
||||
const testFilePath = join(artifactsDir, dir, config.testFile);
|
||||
const testData = readJson(testFilePath);
|
||||
if (!testData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const results =
|
||||
config.framework === "playwright"
|
||||
? parsePlaywrightResults(testData)
|
||||
: parseVitestResults(testData);
|
||||
|
||||
let coverage;
|
||||
if (config.coverageFile) {
|
||||
const coveragePath = join(artifactsDir, dir, config.coverageFile);
|
||||
const coverageData = readJson(coveragePath);
|
||||
if (coverageData) {
|
||||
coverage = parseCoverageSummary(coverageData);
|
||||
}
|
||||
}
|
||||
|
||||
suites.push({ name: displayName, results, coverage });
|
||||
}
|
||||
|
||||
return suites;
|
||||
}
|
||||
|
||||
function buildPrComment(suites) {
|
||||
if (suites.length === 0) {
|
||||
return "## Test Report\n\nNo test results found.\n";
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const totalFailed = suites.reduce((s, r) => s + r.results.failed, 0);
|
||||
const totalFlaky = suites.reduce((s, r) => s + r.results.flaky, 0);
|
||||
const overallIcon =
|
||||
totalFailed > 0 ? "\u274c" : totalFlaky > 0 ? "\u26a0\ufe0f" : "\u2705";
|
||||
|
||||
lines.push(`## ${overallIcon} Test Report`);
|
||||
lines.push("");
|
||||
lines.push("| Suite | Tests | Passed | Failed | Skipped | Duration |");
|
||||
lines.push("|-------|------:|-------:|-------:|--------:|---------:|");
|
||||
|
||||
for (const suite of suites) {
|
||||
const { results } = suite;
|
||||
const icon = getStatusIcon(results);
|
||||
const flaky = results.flaky > 0 ? ` (${results.flaky} flaky)` : "";
|
||||
lines.push(
|
||||
`| ${icon} ${suite.name} | ${results.total} | ${results.passed} | ${results.failed}${flaky} | ${results.skipped} | ${formatDuration(results.duration)} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
const suitesWithCoverage = suites.filter((s) => s.coverage);
|
||||
if (suitesWithCoverage.length > 0) {
|
||||
lines.push("### Coverage");
|
||||
lines.push("");
|
||||
lines.push("| Suite | Lines | Branches | Functions | Statements |");
|
||||
lines.push("|-------|------:|---------:|----------:|-----------:|");
|
||||
|
||||
for (const suite of suitesWithCoverage) {
|
||||
const c = suite.coverage;
|
||||
lines.push(
|
||||
`| ${suite.name} | ${c.lines}% | ${c.branches}% | ${c.functions}% | ${c.statements}% |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
const allFiles = suitesWithCoverage.flatMap(
|
||||
(s) =>
|
||||
s.coverage.files?.map((f) => ({
|
||||
...f,
|
||||
suite: s.name,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
if (allFiles.length > 0) {
|
||||
lines.push("<details>");
|
||||
lines.push(`<summary>File coverage (${allFiles.length} files)</summary>`);
|
||||
lines.push("");
|
||||
|
||||
for (const suite of suitesWithCoverage) {
|
||||
if (!suite.coverage.files?.length) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`#### ${suite.name}`);
|
||||
lines.push("");
|
||||
lines.push("| File | Lines | Branches | Functions |");
|
||||
lines.push("|------|------:|---------:|----------:|");
|
||||
for (const file of suite.coverage.files) {
|
||||
lines.push(
|
||||
`| ${file.file} | ${file.lines}% | ${file.branches}% | ${file.functions}% |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</details>");
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
if (values["pr-comment"]) {
|
||||
const artifactsDir = values["artifacts-dir"];
|
||||
if (!artifactsDir || !existsSync(artifactsDir)) {
|
||||
console.error(`Artifacts directory not found: ${artifactsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const suites = discoverArtifacts(artifactsDir);
|
||||
const markdown = buildPrComment(suites);
|
||||
process.stdout.write(markdown);
|
||||
} else {
|
||||
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!summaryFile) {
|
||||
console.error("GITHUB_STEP_SUMMARY is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testData = readJson(values.json);
|
||||
if (!testData) {
|
||||
const fallback = `### \u26a0\ufe0f ${values.name}\n\nNo test results found at \`${values.json}\`\n\n`;
|
||||
appendFileSync(summaryFile, fallback);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const results =
|
||||
values.framework === "playwright"
|
||||
? parsePlaywrightResults(testData)
|
||||
: parseVitestResults(testData);
|
||||
|
||||
const coverageData = values.coverage ? readJson(values.coverage) : undefined;
|
||||
const coverage = coverageData
|
||||
? parseCoverageSummary(coverageData)
|
||||
: undefined;
|
||||
|
||||
const markdown = buildMarkdown(values.name, results, coverage);
|
||||
appendFileSync(summaryFile, markdown);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
name: Auto-close PRs
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited, labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
parse_template:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action != 'labeled' && github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
close_template:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: ${{ needs.parse_template.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Add label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'labeled' && github.event.label.name == 'auto-closed:llm' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
reopen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse_template
|
||||
if: >-
|
||||
${{
|
||||
needs.parse_template.outputs.uses_template == 'true'
|
||||
&& github.event.pull_request.state == 'closed'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'auto-closed:template')
|
||||
}}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Remove template label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reopen PR
|
||||
if: ${{ steps.check_labels.outputs.remaining == '0' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish Android Artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
security delete-keychain build.keychain || true
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ios-release-ipa
|
||||
path: mobile/ios/Runner.ipa
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Check PR Template
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, edited]
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
|
||||
|
||||
jobs:
|
||||
parse:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check required sections
|
||||
id: check
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
OK=true
|
||||
while IFS= read -r header; do
|
||||
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||
|
||||
act:
|
||||
runs-on: ubuntu-latest
|
||||
needs: parse
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Close PR
|
||||
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
addLabelsToLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Reopen PR (sections now present, PR was auto-closed)
|
||||
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f labelId="$LABEL_ID" \
|
||||
-f query='
|
||||
mutation ReopenPR($prId: ID!, $labelId: ID!) {
|
||||
reopenPullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
removeLabelsFromLabelable(input: {
|
||||
labelableId: $prId,
|
||||
labelIds: [$labelId]
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
@@ -83,13 +83,13 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
file: cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Close LLM-generated PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
comment_and_close:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.label.name == 'llm-generated' }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Comment and close
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||
run: |
|
||||
gh api graphql \
|
||||
-f prId="$NODE_ID" \
|
||||
-f body="Thank you for your interest in contributing to Immich! Unfortunately this PR looks like it was generated using an LLM. As noted in our [CONTRIBUTING.md](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md#use-of-generative-ai), we request that you don't use LLMs to generate PRs as those are not a good use of maintainer time." \
|
||||
-f query='
|
||||
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||
addComment(input: {
|
||||
subjectId: $prId,
|
||||
body: $body
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
|
||||
closePullRequest(input: {
|
||||
pullRequestId: $prId
|
||||
}) {
|
||||
__typename
|
||||
}
|
||||
}'
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: docs-build-output
|
||||
path: docs/build/
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Generate a token
|
||||
id: generate_token
|
||||
if: ${{ inputs.skip != true }}
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
name: 'preview'
|
||||
})
|
||||
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: ${{ github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
message-id: 'preview-status'
|
||||
message: 'PRs from forks cannot have preview environments.'
|
||||
|
||||
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
|
||||
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
|
||||
+86
-348
@@ -75,7 +75,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -94,20 +94,8 @@ jobs:
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run small tests & coverage
|
||||
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "Server Unit Tests" --framework vitest --coverage coverage/coverage-summary.json
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-server-unit
|
||||
path: |
|
||||
server/test-results.json
|
||||
server/coverage/coverage-summary.json
|
||||
retention-days: 1
|
||||
cli-unit-tests:
|
||||
name: Unit Test CLI
|
||||
needs: pre-job
|
||||
@@ -131,7 +119,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -153,18 +141,8 @@ jobs:
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "CLI Unit Tests" --framework vitest
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-cli-unit
|
||||
path: cli/test-results.json
|
||||
retention-days: 1
|
||||
cli-unit-tests-win:
|
||||
name: Unit Test CLI (Windows)
|
||||
needs: pre-job
|
||||
@@ -188,7 +166,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -205,18 +183,8 @@ jobs:
|
||||
run: pnpm check
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "CLI Unit Tests (Windows)" --framework vitest
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-cli-unit-win
|
||||
path: cli/test-results.json
|
||||
retention-days: 1
|
||||
web-lint:
|
||||
name: Lint Web
|
||||
needs: pre-job
|
||||
@@ -240,7 +208,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -284,7 +252,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -300,20 +268,8 @@ jobs:
|
||||
run: pnpm check:typescript
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: pnpm test --reporter=default --reporter=json --outputFile test-results.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results.json --name "Web Unit Tests" --framework vitest --coverage coverage/coverage-summary.json
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-web-unit
|
||||
path: |
|
||||
web/test-results.json
|
||||
web/coverage/coverage-summary.json
|
||||
retention-days: 1
|
||||
i18n-tests:
|
||||
name: Test i18n
|
||||
needs: pre-job
|
||||
@@ -334,7 +290,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -382,7 +338,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -429,7 +385,7 @@ jobs:
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -439,20 +395,8 @@ jobs:
|
||||
- name: Run pnpm install
|
||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
||||
- name: Run medium tests
|
||||
run: pnpm test:medium --reporter=default --reporter=json --outputFile test-results-medium.json --coverage --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json
|
||||
run: pnpm test:medium
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json test-results-medium.json --name "Server Medium Tests" --framework vitest --coverage coverage/coverage-summary.json
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-server-medium
|
||||
path: |
|
||||
server/test-results-medium.json
|
||||
server/coverage/coverage-summary.json
|
||||
retention-days: 1
|
||||
e2e-tests-server-cli:
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
needs: pre-job
|
||||
@@ -460,7 +404,6 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
@@ -473,110 +416,59 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Compute server cache key
|
||||
run: |
|
||||
BUILD_ARGS=$'DEVICE=cpu\n'
|
||||
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
|
||||
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
|
||||
- name: Build Docker images from cache
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
working-directory: ./web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run setup cli
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./cli
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (api & cli)
|
||||
run: docker compose --profile test run --rm e2e-runner pnpm test --reporter=default --reporter=json --outputFile playwright-report/test-results.json
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Server & CLI Tests (${{ matrix.runner }})" --framework vitest
|
||||
- name: Upload test results
|
||||
- name: Run e2e tests (maintenance)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-e2e-server-cli-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/test-results.json
|
||||
retention-days: 1
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-server-maintenance:
|
||||
name: End-to-End Tests (Server Maintenance)
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).server == true || fromJSON(needs.pre-job.outputs.should_run).cli == true }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
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 code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Compute server cache key
|
||||
run: |
|
||||
BUILD_ARGS=$'DEVICE=cpu\n'
|
||||
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
|
||||
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
|
||||
- name: Build Docker images from cache
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (maintenance)
|
||||
run: docker compose --profile test run --rm e2e-runner pnpm test:maintenance --reporter=default --reporter=json --outputFile playwright-report/test-results.json
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Server Maintenance Tests (${{ matrix.runner }})" --framework vitest
|
||||
- name: Upload test results
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-e2e-server-maintenance-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/test-results.json
|
||||
retention-days: 1
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-maintenance-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-web:
|
||||
name: End-to-End Tests (Web)
|
||||
needs: pre-job
|
||||
@@ -584,7 +476,6 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
@@ -597,177 +488,63 @@ jobs:
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Compute server cache key
|
||||
run: |
|
||||
BUILD_ARGS=$'DEVICE=cpu\n'
|
||||
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
|
||||
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
|
||||
- name: Build Docker images from cache
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version-file: './e2e/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
- name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Docker build
|
||||
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (web)
|
||||
run: docker compose --profile test run --rm e2e-runner pnpm test:web
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary (web)
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web Tests (${{ matrix.runner }})" --framework playwright
|
||||
- name: Upload test results (web)
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-e2e-web-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/test-results.json
|
||||
retention-days: 1
|
||||
- name: Archive e2e test (web) results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-web-ui:
|
||||
name: End-to-End Tests (Web UI)
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
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 code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Compute server cache key
|
||||
run: |
|
||||
BUILD_ARGS=$'DEVICE=cpu\n'
|
||||
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
|
||||
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
|
||||
- name: Build Docker images from cache
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run ui tests (web)
|
||||
run: docker compose --profile test run --rm e2e-runner pnpm test:web:ui
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:ui
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary (ui)
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web UI Tests (${{ matrix.runner }})" --framework playwright
|
||||
- name: Upload test results (ui)
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-e2e-web-ui-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/test-results.json
|
||||
retention-days: 1
|
||||
- name: Archive ui test (web) results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-ui-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-web-maintenance:
|
||||
name: End-to-End Tests (Web Maintenance)
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).e2e == true || fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
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 code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- name: Compute server cache key
|
||||
run: |
|
||||
BUILD_ARGS=$'DEVICE=cpu\n'
|
||||
HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
|
||||
ARCH=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$ARCH" == "x64" ]] && ARCH="amd64"
|
||||
echo "SERVER_CACHE_KEY=linux-${ARCH}-${HASH}-main" >> $GITHUB_ENV
|
||||
- name: Build Docker images from cache
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile test build
|
||||
- name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run maintenance tests
|
||||
run: docker compose --profile test run --rm e2e-runner pnpm test:web:maintenance
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
run: pnpm test:web:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Write test summary (maintenance)
|
||||
if: always()
|
||||
run: node ${{ github.workspace }}/.github/scripts/write-test-summary.mjs --json playwright-report/test-results.json --name "E2E Web Maintenance Tests (${{ matrix.runner }})" --framework playwright
|
||||
- name: Upload test results (maintenance)
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: report-e2e-web-maintenance-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/test-results.json
|
||||
retention-days: 1
|
||||
- name: Archive maintenance tests (web) results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
||||
@@ -775,22 +552,16 @@ jobs:
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-web-maintenance-docker-logs-${{ matrix.runner }}
|
||||
name: e2e-web-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
success-check-e2e:
|
||||
name: End-to-End Tests Success
|
||||
needs:
|
||||
[
|
||||
e2e-tests-server-cli,
|
||||
e2e-tests-server-maintenance,
|
||||
e2e-tests-web,
|
||||
e2e-tests-web-ui,
|
||||
e2e-tests-web-maintenance,
|
||||
]
|
||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||
permissions: {}
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
@@ -798,39 +569,6 @@ jobs:
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
test-report:
|
||||
name: PR Test Report
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
needs:
|
||||
- server-unit-tests
|
||||
- web-unit-tests
|
||||
- server-medium-tests
|
||||
- cli-unit-tests
|
||||
- cli-unit-tests-win
|
||||
- e2e-tests-server-cli
|
||||
- e2e-tests-server-maintenance
|
||||
- e2e-tests-web
|
||||
- e2e-tests-web-ui
|
||||
- e2e-tests-web-maintenance
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: 'report-*'
|
||||
path: artifacts
|
||||
- name: Generate unified report
|
||||
run: node .github/scripts/write-test-summary.mjs --pr-comment --artifacts-dir artifacts > pr-comment.md
|
||||
- name: Post PR comment
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
with:
|
||||
header: test-report
|
||||
path: pr-comment.md
|
||||
mobile-unit-tests:
|
||||
name: Unit Test Mobile
|
||||
needs: pre-job
|
||||
@@ -923,7 +661,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -974,7 +712,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
@@ -1036,7 +774,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
|
||||
@@ -24,7 +24,7 @@ e2e-update:
|
||||
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
|
||||
|
||||
e2e-down:
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test down --remove-orphans
|
||||
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
|
||||
|
||||
prod:
|
||||
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
@@ -101,30 +101,10 @@ check-web:
|
||||
pnpm --filter immich-web run check:svelte
|
||||
test-%:
|
||||
pnpm --filter $(call map-package,$*) run test
|
||||
test-e2e: build-e2e test-e2e-server test-e2e-server-maintenance test-e2e-web test-e2e-web-ui test-e2e-web-maintenance
|
||||
|
||||
test-e2e-server:
|
||||
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test
|
||||
|
||||
test-e2e-server-maintenance:
|
||||
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:maintenance
|
||||
|
||||
test-e2e-web:
|
||||
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web
|
||||
|
||||
test-e2e-web-ui:
|
||||
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web:ui
|
||||
|
||||
test-e2e-web-maintenance:
|
||||
docker compose -f ./e2e/docker-compose.yml up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test run --rm e2e-runner pnpm test:web:maintenance
|
||||
|
||||
build-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml --profile test build
|
||||
test-e2e:
|
||||
docker compose -f ./e2e/docker-compose.yml build
|
||||
pnpm --filter immich-e2e run test
|
||||
pnpm --filter immich-e2e run test:web
|
||||
test-medium:
|
||||
docker run \
|
||||
--rm \
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
@@ -35,7 +35,8 @@
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
|
||||
+4
-5
@@ -1,12 +1,10 @@
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { src: '/src' },
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
resolve: { alias: { src: '/src' } },
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
rollupOptions: {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
@@ -18,6 +16,7 @@ export default defineConfig({
|
||||
// bundle everything except for Node built-ins
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
||||
|
||||
## Hardware
|
||||
|
||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
||||
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||
@@ -19,10 +19,6 @@ Hardware and software requirements for Immich:
|
||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||
- Immich runs on the `amd64` and `arm64` platforms.
|
||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
||||
Most CPUs released since ~2012 support this microarchitecture.
|
||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
"label": "v2.6.1",
|
||||
"url": "https://docs.v2.6.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
@@ -71,7 +71,6 @@ const setup = async () => {
|
||||
const redirectUris = [
|
||||
'http://127.0.0.1:2285/auth/login',
|
||||
'https://photos.immich.app/oauth/mobile-redirect',
|
||||
...(process.env.EXTRA_REDIRECT_URIS?.split(',').filter(Boolean) ?? []),
|
||||
];
|
||||
const port = 2286;
|
||||
const host = '0.0.0.0';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@immich/e2e-auth-server",
|
||||
"version": "0.1.0",
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"type": "module",
|
||||
"main": "auth-server.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
docker.io \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .pnpmfile.cjs ./
|
||||
COPY open-api/typescript-sdk/package.json open-api/typescript-sdk/
|
||||
COPY cli/package.json cli/
|
||||
COPY web/package.json web/
|
||||
COPY e2e/package.json e2e/
|
||||
COPY e2e-auth-server/package.json e2e-auth-server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY open-api/typescript-sdk/ open-api/typescript-sdk/
|
||||
RUN pnpm --filter @immich/sdk build
|
||||
|
||||
COPY cli/ cli/
|
||||
RUN pnpm --filter @immich/cli build && ln -s /app/cli/bin/immich /app/cli/node_modules/.bin/immich
|
||||
|
||||
COPY web/svelte.config.js web/vite.config.ts web/tsconfig.json web/
|
||||
COPY web/src/ web/src/
|
||||
COPY web/static/ web/static/
|
||||
RUN pnpm --filter immich-web exec svelte-kit sync
|
||||
|
||||
COPY e2e/ e2e/
|
||||
COPY e2e-auth-server/ e2e-auth-server/
|
||||
|
||||
RUN pnpm --filter immich-e2e exec playwright install --with-deps chromium
|
||||
|
||||
WORKDIR /app/e2e
|
||||
@@ -1,23 +0,0 @@
|
||||
.vscode/
|
||||
.github/
|
||||
.git/
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
**/node_modules/
|
||||
**/.pnpm-store/
|
||||
**/dist/
|
||||
**/coverage/
|
||||
**/build/
|
||||
|
||||
design/
|
||||
docker/
|
||||
docs/
|
||||
fastlane/
|
||||
machine-learning/
|
||||
misc/
|
||||
mobile/
|
||||
server/
|
||||
plugins/
|
||||
i18n/
|
||||
@@ -1,16 +0,0 @@
|
||||
name: immich-e2e
|
||||
|
||||
services:
|
||||
e2e-auth-server:
|
||||
build:
|
||||
cache_from:
|
||||
- type=gha,scope=e2e-auth-${RUNNER_ARCH:-X64}
|
||||
cache_to:
|
||||
- type=gha,mode=max,scope=e2e-auth-${RUNNER_ARCH:-X64}
|
||||
|
||||
e2e-runner:
|
||||
build:
|
||||
cache_from:
|
||||
- type=gha,scope=e2e-runner-${RUNNER_ARCH:-X64}
|
||||
cache_to:
|
||||
- type=gha,mode=max,scope=e2e-runner-${RUNNER_ARCH:-X64}
|
||||
+2
-30
@@ -5,8 +5,6 @@ services:
|
||||
container_name: immich-e2e-auth-server
|
||||
build:
|
||||
context: ../e2e-auth-server
|
||||
environment:
|
||||
EXTRA_REDIRECT_URIS: http://immich-server:2285/auth/login
|
||||
ports:
|
||||
- 2286:2286
|
||||
|
||||
@@ -17,7 +15,8 @@ services:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
cache_from:
|
||||
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:${SERVER_CACHE_KEY:-linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main}
|
||||
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||
- type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main
|
||||
args:
|
||||
- BUILD_ID=1234567890
|
||||
- BUILD_IMAGE=e2e
|
||||
@@ -66,30 +65,3 @@ services:
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
e2e-runner:
|
||||
container_name: immich-e2e-runner
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: e2e/Dockerfile.playwright
|
||||
network_mode: 'service:immich-server'
|
||||
shm_size: 1gb
|
||||
environment:
|
||||
PLAYWRIGHT_DB_HOST: database
|
||||
PLAYWRIGHT_DB_PORT: '5432'
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: 'true'
|
||||
PLAYWRIGHT_AUTH_SERVER_URL: http://e2e-auth-server:2286
|
||||
VITEST_DISABLE_DOCKER_SETUP: 'true'
|
||||
CI: '${CI:-}'
|
||||
volumes:
|
||||
- ./test-assets:/app/e2e/test-assets
|
||||
- ./playwright-report:/app/e2e/playwright-report
|
||||
- ./blob-report:/app/e2e/blob-report
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
depends_on:
|
||||
immich-server:
|
||||
condition: service_started
|
||||
database:
|
||||
condition: service_healthy
|
||||
profiles:
|
||||
- test
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -7,7 +7,6 @@ dotenv.config({ quiet: true, path: resolve(import.meta.dirname, '.env') });
|
||||
|
||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||
export const playwrightDbPort = process.env.PLAYWRIGHT_DB_PORT ?? '5435';
|
||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||
@@ -20,11 +19,7 @@ const config: PlaywrightTestConfig = {
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 4 : 0,
|
||||
reporter: [
|
||||
['html'],
|
||||
['json', { outputFile: 'playwright-report/test-results.json' }],
|
||||
...(process.env.CI ? [['blob', { outputDir: 'blob-report' }] as const] : []),
|
||||
],
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: playwriteBaseUrl,
|
||||
trace: 'on-first-retry',
|
||||
@@ -48,7 +43,7 @@ const config: PlaywrightTestConfig = {
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
testDir: './src/ui/specs',
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 3 : Math.min(10, Math.max(1, Math.round(cpus().length * 0.75) - 1)),
|
||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||
},
|
||||
{
|
||||
name: 'maintenance',
|
||||
|
||||
@@ -524,19 +524,14 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should be able to update as an editor', async () => {
|
||||
it('should not be able to update as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const authServer = {
|
||||
internal: 'http://e2e-auth-server:2286',
|
||||
external: process.env.PLAYWRIGHT_AUTH_SERVER_URL ?? 'http://127.0.0.1:2286',
|
||||
external: 'http://127.0.0.1:2286',
|
||||
};
|
||||
|
||||
const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect';
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('/shared-links', () => {
|
||||
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(resp.header['content-type']).toContain('text/html');
|
||||
expect(resp.text).toContain(`<meta property="og:image" content="${baseUrl}`);
|
||||
expect(resp.text).toContain(`<meta property="og:image" content="http://127.0.0.1:2285`);
|
||||
});
|
||||
|
||||
it('should fall back to my.immich.app og:image meta tag for shared asset if Host header is not present', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Permission } from '@immich/sdk';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { app, baseUrl, immichCli, utils } from 'src/utils';
|
||||
import { app, immichCli, utils } from 'src/utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich login`, () => {
|
||||
@@ -33,7 +33,7 @@ describe(`immich login`, () => {
|
||||
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
`Logging in to ${baseUrl}/api`,
|
||||
'Logging in to http://127.0.0.1:2285/api',
|
||||
'Logged in as admin@immich.cloud',
|
||||
'Wrote auth info to /tmp/immich/auth.yml',
|
||||
]);
|
||||
@@ -50,8 +50,8 @@ describe(`immich login`, () => {
|
||||
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
`Logging in to ${baseUrl}`,
|
||||
`Discovered API at ${baseUrl}/api`,
|
||||
'Logging in to http://127.0.0.1:2285',
|
||||
'Discovered API at http://127.0.0.1:2285/api',
|
||||
'Logged in as admin@immich.cloud',
|
||||
'Wrote auth info to /tmp/immich/auth.yml',
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { baseUrl, immichCli, utils } from 'src/utils';
|
||||
import { immichCli, utils } from 'src/utils';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich server-info`, () => {
|
||||
@@ -12,7 +12,7 @@ describe(`immich server-info`, () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['server-info']);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
expect.stringContaining('Server Info (via admin@immich.cloud'),
|
||||
` Url: ${baseUrl}/api`,
|
||||
' Url: http://127.0.0.1:2285/api',
|
||||
expect.stringContaining('Version:'),
|
||||
' Formats:',
|
||||
expect.stringContaining('Images:'),
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import crypto from 'node:crypto';
|
||||
import { asBearerAuth, utils } from 'src/utils';
|
||||
|
||||
test.describe('Duplicates Utility', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let firstAsset: AssetMediaResponseDto;
|
||||
let secondAsset: AssetMediaResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
[firstAsset, secondAsset] = await Promise.all([
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
|
||||
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
|
||||
]);
|
||||
|
||||
await updateAssets(
|
||||
{
|
||||
assetBulkUpdateDto: {
|
||||
ids: [firstAsset.id, secondAsset.id],
|
||||
duplicateId: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ headers: asBearerAuth(admin.accessToken) },
|
||||
);
|
||||
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
});
|
||||
|
||||
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
|
||||
await page.goto('/utilities/duplicates');
|
||||
await page.getByRole('button', { name: 'View' }).first().click();
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
|
||||
const initialAssetId = getViewedAssetId();
|
||||
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect.poll(getViewedAssetId).toBe(initialAssetId);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { playwrightHost } from 'playwright.config';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Websocket', () => {
|
||||
@@ -14,28 +12,14 @@ test.describe('Websocket', () => {
|
||||
});
|
||||
|
||||
test('connects using ipv4', async ({ page, context }) => {
|
||||
const { address: ipv4 } = await lookup(playwrightHost, 4);
|
||||
await utils.setAuthCookies(context, admin.accessToken, ipv4);
|
||||
await page.goto(`http://${ipv4}:2285/`);
|
||||
await utils.setAuthCookies(context, admin.accessToken);
|
||||
await page.goto('http://127.0.0.1:2285/');
|
||||
await expect(page.locator('#sidebar')).toContainText('Server Online');
|
||||
});
|
||||
|
||||
test('connects using ipv6', async ({ page, context }) => {
|
||||
let ipv6: string;
|
||||
if (playwrightHost === '127.0.0.1') {
|
||||
ipv6 = '::1';
|
||||
} else {
|
||||
try {
|
||||
const { address } = await lookup(playwrightHost, 6);
|
||||
ipv6 = address;
|
||||
} catch {
|
||||
test.skip(true, 'No IPv6 address available');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ipv6Url = `http://[${ipv6}]:2285/`;
|
||||
await utils.setAuthCookies(context, admin.accessToken, undefined, ipv6Url);
|
||||
await page.goto(ipv6Url);
|
||||
await utils.setAuthCookies(context, admin.accessToken, '[::1]');
|
||||
await page.goto('http://[::1]:2285/');
|
||||
await expect(page.locator('#sidebar')).toContainText('Server Online');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
toColumnarFormat,
|
||||
} from './timeline/rest-response';
|
||||
|
||||
export type { Changes } from './timeline/rest-response';
|
||||
export type { Changes, FaceData } from './timeline/rest-response';
|
||||
|
||||
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
AssetVisibility,
|
||||
UserAvatarColor,
|
||||
type AlbumResponseDto,
|
||||
type AssetFaceWithoutPersonResponseDto,
|
||||
type AssetResponseDto,
|
||||
type ExifResponseDto,
|
||||
type PersonWithFacesResponseDto,
|
||||
type TimeBucketAssetResponseDto,
|
||||
type TimeBucketsResponseDto,
|
||||
type UserResponseDto,
|
||||
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
|
||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||
* This matches the response from GET /api/assets/:id
|
||||
*/
|
||||
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
|
||||
export type FaceData = {
|
||||
people: PersonWithFacesResponseDto[];
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto[];
|
||||
};
|
||||
|
||||
export function toAssetResponseDto(
|
||||
asset: MockTimelineAsset,
|
||||
owner?: UserResponseDto,
|
||||
faceData?: FaceData,
|
||||
): AssetResponseDto {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Default owner if not provided
|
||||
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
||||
exifInfo,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
tags: [],
|
||||
people: [],
|
||||
unassignedFaces: [],
|
||||
people: faceData?.people ?? [],
|
||||
unassignedFaces: faceData?.unassignedFaces ?? [],
|
||||
stack: asset.stack,
|
||||
isOffline: false,
|
||||
hasMetadata: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { playwrightHost } from 'playwright.config';
|
||||
|
||||
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
|
||||
@@ -283,13 +283,3 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const waitForServiceWorker = async (page: Page) => {
|
||||
await expect
|
||||
.poll(() => page.context().serviceWorkers().length, {
|
||||
message:
|
||||
'Service worker not registered. Ensure the origin is a secure context (localhost or use --unsafely-treat-insecure-origin-as-secure flag).',
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||
import { type FaceData, randomThumbnail } from 'src/ui/generators/timeline';
|
||||
|
||||
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||
const MINIMAL_MP4_BASE64 =
|
||||
@@ -125,3 +126,84 @@ export const setupFaceEditorMockApiRoutes = async (
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export type MockFaceSpec = {
|
||||
personId: string;
|
||||
personName: string;
|
||||
faceId: string;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY2: number;
|
||||
};
|
||||
|
||||
const toPersonResponseDto = (spec: MockFaceSpec) => ({
|
||||
id: spec.personId,
|
||||
name: spec.personName,
|
||||
birthDate: null,
|
||||
isHidden: false,
|
||||
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const toBoundingBox = (spec: MockFaceSpec, imageWidth: number, imageHeight: number) => ({
|
||||
id: spec.faceId,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boundingBoxX1: spec.boundingBoxX1,
|
||||
boundingBoxY1: spec.boundingBoxY1,
|
||||
boundingBoxX2: spec.boundingBoxX2,
|
||||
boundingBoxY2: spec.boundingBoxY2,
|
||||
});
|
||||
|
||||
export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): FaceData => {
|
||||
const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({
|
||||
...toPersonResponseDto(spec),
|
||||
faces: [toBoundingBox(spec, imageWidth, imageHeight)],
|
||||
}));
|
||||
|
||||
return { people, unassignedFaces: [] };
|
||||
};
|
||||
|
||||
export const createMockAssetFaces = (
|
||||
specs: MockFaceSpec[],
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): AssetFaceResponseDto[] => {
|
||||
return specs.map((spec) => ({
|
||||
...toBoundingBox(spec, imageWidth, imageHeight),
|
||||
person: toPersonResponseDto(spec),
|
||||
sourceType: 'machine-learning' as SourceType,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setupGetFacesMockApiRoute = async (context: BrowserContext, faces: AssetFaceResponseDto[]) => {
|
||||
await context.route('**/api/faces?*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: faces,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const setupFaceOverlayMockApiRoutes = async (context: BrowserContext, assetDto: AssetResponseDto) => {
|
||||
await context.route('**/api/assets/*', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const assetId = url.pathname.split('/').at(-1);
|
||||
if (assetId !== assetDto.id) {
|
||||
return route.fallback();
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: assetDto,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetOcrResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MockOcrBox = {
|
||||
text: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
x3: number;
|
||||
y3: number;
|
||||
x4: number;
|
||||
y4: number;
|
||||
};
|
||||
|
||||
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
|
||||
return boxes.map((box) => ({
|
||||
id: faker.string.uuid(),
|
||||
assetId,
|
||||
x1: box.x1,
|
||||
y1: box.y1,
|
||||
x2: box.x2,
|
||||
y2: box.y2,
|
||||
x3: box.x3,
|
||||
y3: box.y3,
|
||||
x4: box.x4,
|
||||
y4: box.y4,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.9,
|
||||
text: box.text,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setupOcrMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
|
||||
) => {
|
||||
await context.route('**/assets/*/ocr', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetIdIndex = segments.indexOf('assets') + 1;
|
||||
const assetId = segments[assetIdIndex];
|
||||
|
||||
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: ocrData,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
const waitForSelectorTransition = async (page: Page) => {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||
if (!selector) {
|
||||
return false;
|
||||
}
|
||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 1000, polling: 50 },
|
||||
await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
|
||||
await page.locator('#face-selector').evaluate(
|
||||
(el) =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
const animations = el.getAnimations();
|
||||
if (animations.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
void Promise.all(animations.map((a) => a.finished)).then(() => resolve());
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,7 +100,7 @@ test.describe('face-editor', () => {
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
await waitForSelectorTransition(page);
|
||||
};
|
||||
|
||||
test('Face editor opens with person list', async ({ page }) => {
|
||||
@@ -149,7 +154,7 @@ test.describe('face-editor', () => {
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||
test('Confirming tag calls createFace API with valid coordinates and closes editor', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
@@ -163,8 +168,15 @@ test.describe('face-editor', () => {
|
||||
await expect(page.locator('#face-editor')).toBeHidden();
|
||||
|
||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||
const request = faceCreateCapture.requests[0];
|
||||
expect(request.assetId).toBe(asset.id);
|
||||
expect(request.personId).toBe(personToTag.id);
|
||||
expect(request.x).toBeGreaterThanOrEqual(0);
|
||||
expect(request.y).toBeGreaterThanOrEqual(0);
|
||||
expect(request.width).toBeGreaterThan(0);
|
||||
expect(request.height).toBeGreaterThan(0);
|
||||
expect(request.x + request.width).toBeLessThanOrEqual(request.imageWidth);
|
||||
expect(request.y + request.height).toBeLessThanOrEqual(request.imageHeight);
|
||||
});
|
||||
|
||||
test('Cancel button closes face editor', async ({ page }) => {
|
||||
@@ -282,4 +294,39 @@ test.describe('face-editor', () => {
|
||||
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||
});
|
||||
|
||||
test('Cancel on confirmation dialog keeps face editor open', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const personToTag = mockPeople[0];
|
||||
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /cancel/i })
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
await expect(page.locator('#face-selector')).toBeVisible();
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
expect(faceCreateCapture.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Clicking on face rect center does not reposition it', async ({ page }) => {
|
||||
const asset = selectRandom(fixture.assets, rng);
|
||||
await openFaceEditor(page, asset);
|
||||
|
||||
const beforeClick = await getFaceBoxRect(page);
|
||||
const centerX = beforeClick.left + beforeClick.width / 2;
|
||||
const centerY = beforeClick.top + beforeClick.height / 2;
|
||||
|
||||
await page.mouse.click(centerX, centerY);
|
||||
await waitForSelectorTransition(page);
|
||||
|
||||
const afterClick = await getFaceBoxRect(page);
|
||||
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
|
||||
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockAssetFaces,
|
||||
createMockFaceData,
|
||||
createMockPeople,
|
||||
type MockFaceSpec,
|
||||
setupFaceEditorMockApiRoutes,
|
||||
setupFaceOverlayMockApiRoutes,
|
||||
setupGetFacesMockApiRoute,
|
||||
} from 'src/ui/mock-network/face-editor-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const FACE_SPECS: MockFaceSpec[] = [
|
||||
{
|
||||
personId: 'person-alice',
|
||||
personName: 'Alice Johnson',
|
||||
faceId: 'face-alice',
|
||||
boundingBoxX1: 1000,
|
||||
boundingBoxY1: 500,
|
||||
boundingBoxX2: 1500,
|
||||
boundingBoxY2: 1200,
|
||||
},
|
||||
{
|
||||
personId: 'person-bob',
|
||||
personName: 'Bob Smith',
|
||||
faceId: 'face-bob',
|
||||
boundingBoxX1: 2000,
|
||||
boundingBoxY1: 800,
|
||||
boundingBoxX2: 2400,
|
||||
boundingBoxY2: 1600,
|
||||
},
|
||||
];
|
||||
|
||||
const setupFaceMocks = async (
|
||||
context: import('@playwright/test').BrowserContext,
|
||||
fixture: ReturnType<typeof setupAssetViewerFixture>,
|
||||
) => {
|
||||
const mockPeople = createMockPeople(4);
|
||||
const faceData = createMockFaceData(
|
||||
FACE_SPECS,
|
||||
fixture.primaryAssetDto.width ?? 3000,
|
||||
fixture.primaryAssetDto.height ?? 4000,
|
||||
);
|
||||
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
|
||||
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces);
|
||||
await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] });
|
||||
};
|
||||
|
||||
test.describe('face overlay bounding boxes', () => {
|
||||
const fixture = setupAssetViewerFixture(901);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupFaceMocks(context, fixture);
|
||||
});
|
||||
|
||||
test('face overlay divs render with correct aria labels', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
||||
const bobOverlay = page.getByLabel('Person: Bob Smith');
|
||||
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
await expect(bobOverlay).toBeVisible();
|
||||
});
|
||||
|
||||
test('face overlay shows border on hover', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await expect(activeBorder).toHaveCount(0);
|
||||
|
||||
await aliceOverlay.hover();
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('face name tooltip appears on hover', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
|
||||
await aliceOverlay.hover();
|
||||
|
||||
const nameTooltip = page.locator('[data-viewer-content]').getByText('Alice Johnson');
|
||||
await expect(nameTooltip).toBeVisible();
|
||||
});
|
||||
|
||||
test('face overlays hidden in face edit mode', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
await expect(aliceOverlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('face overlay hover works after exiting face edit mode', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const aliceOverlay = page.getByLabel('Person: Alice Johnson');
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
await expect(aliceOverlay).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
|
||||
await expect(aliceOverlay).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await expect(activeBorder).toHaveCount(0);
|
||||
await aliceOverlay.hover();
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('zoom and face editor interaction', () => {
|
||||
const fixture = setupAssetViewerFixture(902);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupFaceMocks(context, fixture);
|
||||
});
|
||||
|
||||
test('zoom is preserved when entering face edit mode', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(transform).not.toBe('none');
|
||||
expect(transform).not.toBe('');
|
||||
}).toPass({ timeout: 2000 });
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
await expect(page.locator('#face-editor')).toBeVisible();
|
||||
|
||||
const afterTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(afterTransform).not.toBe('none');
|
||||
});
|
||||
|
||||
test('modifier+drag pans zoomed image without repositioning face rect', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.mouse.wheel(0, -3);
|
||||
}
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]');
|
||||
await expect(async () => {
|
||||
const transform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(transform).not.toBe('none');
|
||||
}).toPass({ timeout: 2000 });
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
const dataEl = page.locator('#face-editor-data');
|
||||
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||
const beforeLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const beforeTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const transformBefore = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const panModifier = await page.evaluate(() =>
|
||||
/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control',
|
||||
);
|
||||
await page.keyboard.down(panModifier);
|
||||
|
||||
// Verify face editor becomes transparent to pointer events
|
||||
await expect(async () => {
|
||||
const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents);
|
||||
expect(pe).toBe('none');
|
||||
}).toPass({ timeout: 2000 });
|
||||
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.keyboard.up(panModifier);
|
||||
|
||||
const transformAfter = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(transformAfter).not.toBe(transformBefore);
|
||||
|
||||
// Extract translate values from matrix(a, b, c, d, tx, ty)
|
||||
const parseTranslate = (matrix: string) => {
|
||||
const values =
|
||||
matrix
|
||||
.match(/matrix\((.+)\)/)?.[1]
|
||||
.split(',')
|
||||
.map(Number) ?? [];
|
||||
return { tx: values[4], ty: values[5] };
|
||||
};
|
||||
const panBefore = parseTranslate(transformBefore);
|
||||
const panAfter = parseTranslate(transformAfter);
|
||||
const panDeltaX = panAfter.tx - panBefore.tx;
|
||||
const panDeltaY = panAfter.ty - panBefore.ty;
|
||||
|
||||
// Face rect screen position should have moved by the same amount as the pan
|
||||
// (it follows the image), NOT been repositioned by a click
|
||||
const afterLeft = Number(await dataEl.getAttribute('data-face-left'));
|
||||
const afterTop = Number(await dataEl.getAttribute('data-face-top'));
|
||||
const faceDeltaX = afterLeft - beforeLeft;
|
||||
const faceDeltaY = afterTop - beforeTop;
|
||||
expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3);
|
||||
expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('face overlay via detail panel interaction', () => {
|
||||
const fixture = setupAssetViewerFixture(903);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupFaceMocks(context, fixture);
|
||||
});
|
||||
|
||||
test('hovering person in detail panel shows face overlay border', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
||||
await expect(personLink).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await expect(activeBorder).toHaveCount(0);
|
||||
|
||||
await personLink.hover();
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('touch pointer on person in detail panel shows face overlay border', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
|
||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
||||
await expect(personLink).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await expect(activeBorder).toHaveCount(0);
|
||||
|
||||
// Simulate a touch-type pointerover (the fix changed from onmouseover to onpointerover,
|
||||
// which fires for touch pointers unlike mouseover)
|
||||
await personLink.dispatchEvent('pointerover', { pointerType: 'touch' });
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('hovering person in detail panel works after exiting face edit mode', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Tag people').click();
|
||||
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(page.locator('#face-selector')).toBeHidden();
|
||||
|
||||
const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' });
|
||||
await expect(personLink).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await personLink.hover();
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('face overlay via edit faces side panel', () => {
|
||||
const fixture = setupAssetViewerFixture(904);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupFaceMocks(context, fixture);
|
||||
|
||||
const assetFaces = createMockAssetFaces(
|
||||
FACE_SPECS,
|
||||
fixture.primaryAssetDto.width ?? 3000,
|
||||
fixture.primaryAssetDto.height ?? 4000,
|
||||
);
|
||||
await setupGetFacesMockApiRoute(context, assetFaces);
|
||||
});
|
||||
|
||||
test('hovering person in edit faces panel shows face overlay border', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await ensureDetailPanelVisible(page);
|
||||
await page.getByLabel('Edit people').click();
|
||||
|
||||
const faceThumbnail = page.getByTestId('face-thumbnail').first();
|
||||
await expect(faceThumbnail).toBeVisible();
|
||||
|
||||
const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3');
|
||||
await expect(activeBorder).toHaveCount(0);
|
||||
|
||||
await faceThumbnail.hover();
|
||||
await expect(activeBorder).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { AssetOcrResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||
import {
|
||||
createMockStack,
|
||||
createMockStackAsset,
|
||||
MockStack,
|
||||
setupBrokenAssetMockApiRoutes,
|
||||
} from 'src/ui/mock-network/broken-asset-network';
|
||||
import { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const PRIMARY_OCR_BOXES = [
|
||||
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
|
||||
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
|
||||
];
|
||||
|
||||
const SECONDARY_OCR_BOXES = [
|
||||
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
|
||||
];
|
||||
|
||||
test.describe('OCR bounding boxes', () => {
|
||||
const fixture = setupAssetViewerFixture(920);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||
await expect(ocrBoxes).toHaveCount(2);
|
||||
|
||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
|
||||
});
|
||||
|
||||
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await ocrButton.click();
|
||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
|
||||
|
||||
await ocrButton.click();
|
||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR with stacked assets', () => {
|
||||
const fixture = setupAssetViewerFixture(921);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||
await expect(ocrBoxes).toHaveCount(2);
|
||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||
|
||||
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackThumbnails).toHaveCount(2);
|
||||
await stackThumbnails.nth(1).click();
|
||||
|
||||
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
||||
await expect(ocrBoxes).toHaveCount(0);
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
await expect(ocrBoxes).toHaveCount(1);
|
||||
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR boxes and zoom', () => {
|
||||
const fixture = setupAssetViewerFixture(922);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR boxes scale with zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const initialBox = await ocrBox.boundingBox();
|
||||
expect(initialBox).toBeTruthy();
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -3);
|
||||
|
||||
await expect(async () => {
|
||||
const zoomedBox = await ocrBox.boundingBox();
|
||||
expect(zoomedBox).toBeTruthy();
|
||||
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
|
||||
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
|
||||
}).toPass({ timeout: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR text interaction', () => {
|
||||
const fixture = setupAssetViewerFixture(923);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
|
||||
});
|
||||
|
||||
test('OCR text box receives focus on click', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
await ocrBox.click();
|
||||
await expect(ocrBox).toBeFocused();
|
||||
});
|
||||
|
||||
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
const initialTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const box = await ocrBox.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const centerX = box!.x + box!.width / 2;
|
||||
const centerY = box!.y + box!.height / 2;
|
||||
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const afterTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(afterTransform).toBe(initialTransform);
|
||||
});
|
||||
|
||||
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
const initialTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const viewerContent = page.locator('[data-viewer-content]');
|
||||
const viewerBox = await viewerContent.boundingBox();
|
||||
expect(viewerBox).toBeTruthy();
|
||||
|
||||
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
|
||||
await page.evaluate(
|
||||
({ viewerCenterX, viewerCenterY, outsideY }) => {
|
||||
const viewer = document.querySelector('[data-viewer-content]');
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createTouch = (id: number, x: number, y: number) => {
|
||||
return new Touch({
|
||||
identifier: id,
|
||||
target: viewer,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
});
|
||||
};
|
||||
|
||||
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
|
||||
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
|
||||
|
||||
const touchStartEvent = new TouchEvent('touchstart', {
|
||||
touches: [insideTouch, outsideTouch],
|
||||
targetTouches: [insideTouch],
|
||||
changedTouches: [insideTouch, outsideTouch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const touchMoveEvent = new TouchEvent('touchmove', {
|
||||
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
|
||||
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
|
||||
changedTouches: [
|
||||
createTouch(0, viewerCenterX, viewerCenterY - 30),
|
||||
createTouch(1, viewerCenterX, outsideY + 30),
|
||||
],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const touchEndEvent = new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
targetTouches: [],
|
||||
changedTouches: [insideTouch, outsideTouch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
viewer.dispatchEvent(touchStartEvent);
|
||||
viewer.dispatchEvent(touchMoveEvent);
|
||||
viewer.dispatchEvent(touchEndEvent);
|
||||
},
|
||||
{
|
||||
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
|
||||
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
|
||||
outsideY: 10, // near the top of the page, outside the viewer
|
||||
},
|
||||
);
|
||||
|
||||
const afterTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(afterTransform).toBe(initialTransform);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
import { waitForServiceWorker } from 'src/ui/mock-network/base-network';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
@@ -16,7 +15,6 @@ export const memoryViewerUtils = {
|
||||
},
|
||||
|
||||
async waitForMemoryLoad(page: Page) {
|
||||
await waitForServiceWorker(page);
|
||||
await expect(this.locator(page)).toBeVisible();
|
||||
await expect(page.locator('#memory-viewer img').first()).toBeVisible();
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
|
||||
|
||||
await context.route('**/api/search/metadata', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||
const searchAssets = assets
|
||||
.slice(0, 5)
|
||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BrowserContext, expect, Page } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import { TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||
import { waitForServiceWorker } from 'src/ui/mock-network/base-network';
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -144,8 +143,7 @@ export const timelineUtils = {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await waitForServiceWorker(page);
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await page.locator('#asset-grid[data-initialized]').waitFor();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
@@ -165,15 +163,17 @@ export const assetViewerUtils = {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await waitForServiceWorker(page);
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.waitFor();
|
||||
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
|
||||
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
|
||||
await imgLocator.or(videoLocator).waitFor();
|
||||
|
||||
if ((await videoLocator.count()) === 0) {
|
||||
await expect
|
||||
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
|
||||
+10
-9
@@ -71,7 +71,7 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import { loginDto, signupDto } from 'src/fixtures';
|
||||
import { makeRandomImage } from 'src/generators';
|
||||
import request from 'supertest';
|
||||
import { playwrightDbHost, playwrightDbPort, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
|
||||
|
||||
export type { Emitter } from '@socket.io/component-emitter';
|
||||
|
||||
@@ -81,7 +81,7 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
|
||||
type AdminSetupOptions = { onboarding?: boolean };
|
||||
type FileData = { bytes?: Buffer; filename: string };
|
||||
|
||||
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:${playwrightDbPort}/immich`;
|
||||
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
|
||||
export const baseUrl = playwriteBaseUrl;
|
||||
export const shareUrl = `${baseUrl}/share`;
|
||||
export const app = `${baseUrl}/api`;
|
||||
@@ -522,13 +522,13 @@ export const utils = {
|
||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost, url?: string) => {
|
||||
const origin = url ? { url } : { domain, path: '/' };
|
||||
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'immich_access_token',
|
||||
value: accessToken,
|
||||
...origin,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: 2_058_028_213,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
@@ -537,7 +537,8 @@ export const utils = {
|
||||
{
|
||||
name: 'immich_auth_type',
|
||||
value: 'password',
|
||||
...origin,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: 2_058_028_213,
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
@@ -546,14 +547,14 @@ export const utils = {
|
||||
{
|
||||
name: 'immich_is_authenticated',
|
||||
value: 'true',
|
||||
...origin,
|
||||
domain,
|
||||
path: '/',
|
||||
expires: 2_058_028_213,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax',
|
||||
},
|
||||
]);
|
||||
},
|
||||
]),
|
||||
|
||||
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
|
||||
await context.addCookies([
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
Changes,
|
||||
createDefaultTimelineConfig,
|
||||
generateTimelineData,
|
||||
SeededRandom,
|
||||
selectRandom,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
import { assetViewerUtils } from 'src/ui/specs/timeline/utils';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
test.describe('asset-viewer', () => {
|
||||
const rng = new SeededRandom(529);
|
||||
let adminUserId: string;
|
||||
let timelineRestData: TimelineData;
|
||||
const assets: TimelineAssetConfig[] = [];
|
||||
const yearMonths: string[] = [];
|
||||
const testContext = new TimelineTestContext();
|
||||
const changes: Changes = {
|
||||
albumAdditions: [],
|
||||
assetDeletions: [],
|
||||
assetArchivals: [],
|
||||
assetFavorites: [],
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||
assets.push(...timeBucket);
|
||||
}
|
||||
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||
const [year, month] = yearMonth.split('-');
|
||||
yearMonths.push(`${year}-${Number(month)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBaseMockApiRoutes(context, adminUserId);
|
||||
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
testContext.slowBucket = false;
|
||||
changes.albumAdditions = [];
|
||||
changes.assetDeletions = [];
|
||||
changes.assetArchivals = [];
|
||||
changes.assetFavorites = [];
|
||||
});
|
||||
|
||||
test.describe('/photos/:id', () => {
|
||||
test('Navigate to next asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||
});
|
||||
|
||||
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View next asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await page.getByLabel('View previous asset').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
|
||||
// Navigate forward 3 times
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.getByTestId('next-asset').waitFor();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Navigate backward 3 times to return to original
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
await page.getByTestId('previous-asset').waitFor();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||
}
|
||||
|
||||
// Verify we're back at the original asset
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||
});
|
||||
|
||||
test('Verify no next button on last asset', async ({ page }) => {
|
||||
const lastAsset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${lastAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||
|
||||
// Verify next button doesn't exist
|
||||
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Verify no previous button on first asset', async ({ page }) => {
|
||||
const firstAsset = assets[0];
|
||||
await page.goto(`/photos/${firstAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||
|
||||
// Verify previous button doesn't exist
|
||||
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Delete photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete last photo advances to prev', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
});
|
||||
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||
const asset = assets.at(-1)!;
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
const index = assets.indexOf(asset);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||
});
|
||||
});
|
||||
test.describe('/trash/photos/:id', () => {
|
||||
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
});
|
||||
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${asset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
});
|
||||
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||
const asset = selectRandom(assets, rng);
|
||||
const index = assets.indexOf(asset);
|
||||
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||
changes.assetDeletions.push(...deletedAssets);
|
||||
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||
await page.getByLabel('Delete').click();
|
||||
// confirm dialog
|
||||
await page.getByRole('button').getByText('Delete').click();
|
||||
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1275,6 +1275,7 @@
|
||||
"hide_schema": "Hide schema",
|
||||
"hide_text_recognition": "Hide text recognition",
|
||||
"hide_unnamed_people": "Hide unnamed people",
|
||||
"hold_key_to_pan": "Hold {key} to pan",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.2"
|
||||
version = "2.6.1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.2"
|
||||
version = "2.6.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
"android.injected.version.code" => 3039,
|
||||
"android.injected.version.name" => "2.6.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.2</string>
|
||||
<string>2.6.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -79,7 +79,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
final person = people[index];
|
||||
|
||||
return Column(
|
||||
key: ValueKey(person.id),
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -89,7 +88,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
shape: const CircleBorder(side: BorderSide.none),
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
key: ValueKey('avatar-${person.id}'),
|
||||
maxRadius: isTablet ? 100 / 2 : 96 / 2,
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
|
||||
@@ -69,7 +69,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final previousFilter = useState<SearchFilter?>(null);
|
||||
final hasRequestedSearch = useState<bool>(false);
|
||||
final dateInputFilter = useState<DateFilterInputModel?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
@@ -92,11 +91,9 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
|
||||
if (filter.isEmpty) {
|
||||
previousFilter.value = null;
|
||||
hasRequestedSearch.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hasRequestedSearch.value = true;
|
||||
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
|
||||
previousFilter.value = filter;
|
||||
}
|
||||
@@ -110,8 +107,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
searchPreFilter() {
|
||||
if (preFilter != null) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
filter.value = preFilter;
|
||||
textSearchController.clear();
|
||||
searchFilter(preFilter);
|
||||
|
||||
if (preFilter.location.city != null) {
|
||||
@@ -724,7 +719,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!hasRequestedSearch.value)
|
||||
if (filter.value.isEmpty)
|
||||
const _SearchSuggestions()
|
||||
else
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
|
||||
|
||||
+14
-16
@@ -24,22 +24,20 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
ref.invalidate(assetViewerProvider);
|
||||
ref.invalidate(paginatedSearchProvider);
|
||||
|
||||
ref.read(searchPreFilterProvider.notifier)
|
||||
..clear()
|
||||
..setFilter(
|
||||
SearchFilter(
|
||||
assetId: assetId,
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
ref
|
||||
.read(searchPreFilterProvider.notifier)
|
||||
.setFilter(
|
||||
SearchFilter(
|
||||
assetId: assetId,
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||
}
|
||||
|
||||
@@ -39,16 +39,6 @@ class _RatingBarState extends State<RatingBar> {
|
||||
_currentRating = widget.initialRating;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RatingBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
|
||||
setState(() {
|
||||
_currentRating = widget.initialRating;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
|
||||
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
|
||||
double dx = localPosition.dx;
|
||||
|
||||
@@ -67,9 +67,6 @@ class AuthService {
|
||||
bool isValid = false;
|
||||
|
||||
try {
|
||||
final urls = ApiService.getServerUrls();
|
||||
urls.add(url);
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
|
||||
final uri = Uri.parse('$url/users/me');
|
||||
final response = await NetworkRepository.client.get(uri);
|
||||
if (response.statusCode == 200) {
|
||||
|
||||
@@ -143,7 +143,8 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.setAlbumCover =>
|
||||
!context.isInLockedView && //
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null && //
|
||||
context.selectedCount == 1,
|
||||
ActionButtonType.unstack =>
|
||||
|
||||
@@ -16,15 +16,9 @@ class SearchDropdown<T> extends StatelessWidget {
|
||||
final Widget? label;
|
||||
final Widget? leadingIcon;
|
||||
|
||||
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
|
||||
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
|
||||
const menuStyle = MenuStyle(
|
||||
final menuStyle = const MenuStyle(
|
||||
shape: WidgetStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
|
||||
),
|
||||
@@ -32,26 +26,11 @@ class SearchDropdown<T> extends StatelessWidget {
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final styledEntries = dropdownMenuEntries
|
||||
.map(
|
||||
(entry) => DropdownMenuEntry<T>(
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
labelWidget: entry.labelWidget,
|
||||
enabled: entry.enabled,
|
||||
leadingIcon: entry.leadingIcon,
|
||||
trailingIcon: entry.trailingIcon,
|
||||
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
return DropdownMenu(
|
||||
controller: controller,
|
||||
leadingIcon: leadingIcon,
|
||||
width: constraints.maxWidth,
|
||||
menuHeight: maxMenuHeight,
|
||||
dropdownMenuEntries: styledEntries,
|
||||
dropdownMenuEntries: dropdownMenuEntries,
|
||||
label: label,
|
||||
menuStyle: menuStyle,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.2
|
||||
- API version: 2.6.1
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.2+3040
|
||||
version: 2.6.1+3039
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -727,7 +727,7 @@ void main() {
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should show when not owner', () {
|
||||
test('should not show when not owner', () {
|
||||
final album = createRemoteAlbum();
|
||||
final context = ActionButtonContext(
|
||||
asset: mergedAsset,
|
||||
@@ -742,7 +742,7 @@ void main() {
|
||||
selectedCount: 1,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when in locked view', () {
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.2
|
||||
* 2.6.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
Generated
+250
-527
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -52,7 +52,7 @@ FROM builder AS plugins
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./plugins/mise.toml ./plugins/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -190,13 +190,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumUpdate: {
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.AlbumDelete: {
|
||||
@@ -204,13 +198,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumShare: {
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.AlbumDownload: {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -72,10 +72,10 @@
|
||||
"@koddsson/eslint-plugin-tscompat": "^0.2.0",
|
||||
"@socket.io/component-emitter": "^3.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/enhanced-img": "^0.10.0",
|
||||
"@sveltejs/kit": "^2.27.1",
|
||||
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/svelte": "^5.2.8",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
@@ -103,10 +103,10 @@
|
||||
"svelte": "5.53.13",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^7.1.2",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
+396
@@ -75,6 +75,35 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* transitions */
|
||||
--immich-split-viewer-nav: enabled;
|
||||
|
||||
/* view transition variables */
|
||||
/* Base animation duration for standard transitions (page fades, info panel) */
|
||||
--vt-duration-default: 250ms;
|
||||
/* Duration for hero transitions (thumbnail to full viewer) */
|
||||
--vt-duration-hero: 280ms;
|
||||
/* Duration for next/previous photo navigation */
|
||||
--vt-duration-viewer-navigation: 270ms;
|
||||
/* Duration for slideshow mode transitions */
|
||||
--vt-duration-slideshow: 1s;
|
||||
/* Easing function for slide animations (ease-out) */
|
||||
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
/* How far images slide in/out during navigation (% of viewport) */
|
||||
--vt-viewer-slide-distance: 15%;
|
||||
/* Starting opacity for fly transitions (slide+fade effect) */
|
||||
--vt-viewer-opacity-start: 0.1;
|
||||
/* Maximum blur during fly transitions (currently disabled) */
|
||||
--vt-viewer-blur-max: 0px;
|
||||
|
||||
--vt-viewer-next-in: flyInRight;
|
||||
--vt-viewer-next-out: flyOutLeft;
|
||||
--vt-viewer-prev-in: flyInLeft;
|
||||
--vt-viewer-prev-out: flyOutRight;
|
||||
--vt-viewer-old-opacity: 1;
|
||||
/* Easing function for memory and hero morph transitions */
|
||||
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -176,3 +205,370 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::view-transition {
|
||||
background: var(--color-black);
|
||||
animation-duration: var(--vt-duration-default);
|
||||
}
|
||||
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
mix-blend-mode: normal;
|
||||
animation-duration: inherit;
|
||||
}
|
||||
|
||||
::view-transition-old(*) {
|
||||
animation-name: fadeOut;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
::view-transition-new(*) {
|
||||
animation-name: fadeIn;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
html:active-view-transition-type(slideshow) {
|
||||
&::view-transition-old(*) {
|
||||
animation: var(--vt-duration-slideshow) linear crossfadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(*) {
|
||||
animation: var(--vt-duration-slideshow) linear crossfadeIn forwards;
|
||||
}
|
||||
&::view-transition-image-pair(*) {
|
||||
isolation: auto;
|
||||
}
|
||||
}
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
::view-transition-image-pair(info) {
|
||||
isolation: auto;
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(detail-panel) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(detail-panel),
|
||||
::view-transition-new(detail-panel) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
animation-duration: var(--vt-duration-viewer-navigation);
|
||||
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
::view-transition-image-pair(letterbox-left),
|
||||
::view-transition-image-pair(letterbox-right),
|
||||
::view-transition-image-pair(letterbox-top),
|
||||
::view-transition-image-pair(letterbox-bottom) {
|
||||
isolation: auto;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom),
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-leftbutton),
|
||||
::view-transition-group(exclude-rightbutton),
|
||||
::view-transition-group(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
::view-transition-old(exclude-leftbutton),
|
||||
::view-transition-old(exclude-rightbutton),
|
||||
::view-transition-old(exclude) {
|
||||
visibility: hidden;
|
||||
}
|
||||
::view-transition-new(exclude-leftbutton),
|
||||
::view-transition-new(exclude-rightbutton),
|
||||
::view-transition-new(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
}
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new),
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new) {
|
||||
animation-duration: var(--vt-duration-viewer-navigation);
|
||||
animation-timing-function: var(--vt-viewer-slide-easing);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old) {
|
||||
opacity: var(--vt-viewer-old-opacity);
|
||||
}
|
||||
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation-name: var(--vt-viewer-next-out);
|
||||
}
|
||||
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation-name: var(--vt-viewer-next-in);
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old) {
|
||||
animation-name: var(--vt-viewer-prev-out);
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new) {
|
||||
animation-name: var(--vt-viewer-prev-in);
|
||||
}
|
||||
|
||||
::view-transition-old(next-old),
|
||||
::view-transition-new(next-new),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-new(previous-new) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-old(previous-old) {
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInRight {
|
||||
from {
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes crossfadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes crossfadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(hero) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(viewer) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(timeline) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom),
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(previous-old),
|
||||
::view-transition-group(next),
|
||||
::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
// Minimal touch shape — avoids importing DOM TouchEvent which isn't available in all TS targets.
|
||||
type TouchEventLike = {
|
||||
touches: Iterable<{ clientX: number; clientY: number }> & { length: number };
|
||||
targetTouches: ArrayLike<unknown>;
|
||||
};
|
||||
const asTouchEvent = (event: Event) => event as unknown as TouchEventLike;
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { zoomTarget?: HTMLElement }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: null,
|
||||
zoomTarget: options?.zoomTarget,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
@@ -13,47 +20,130 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
const onInteractionStart = (event: Event) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
node.addEventListener('pointerdown', () => assetViewerManager.cancelZoomAnimation(), { capture: true, signal });
|
||||
|
||||
// Intercept events in capture phase to prevent zoom-image from seeing interactions on
|
||||
// overlay elements (e.g. OCR text boxes), preserving browser defaults like text selection.
|
||||
const isOverlayEvent = (event: Event) => !!(event.target as HTMLElement).closest('[data-overlay-interactive]');
|
||||
const isOverlayAtPoint = (x: number, y: number) =>
|
||||
!!document.elementFromPoint(x, y)?.closest('[data-overlay-interactive]');
|
||||
|
||||
// Pointer event interception: track pointers that start on overlays and intercept the entire gesture.
|
||||
const overlayPointers = new Set<number>();
|
||||
const interceptedPointers = new Set<number>();
|
||||
const interceptOverlayPointerDown = (event: PointerEvent) => {
|
||||
if (isOverlayEvent(event) || isOverlayAtPoint(event.clientX, event.clientY)) {
|
||||
overlayPointers.add(event.pointerId);
|
||||
interceptedPointers.add(event.pointerId);
|
||||
event.stopPropagation();
|
||||
} else if (overlayPointers.size > 0) {
|
||||
// Split gesture (e.g. pinch with one finger on overlay) — intercept entirely.
|
||||
interceptedPointers.add(event.pointerId);
|
||||
event.stopPropagation();
|
||||
}
|
||||
assetViewerManager.cancelZoomAnimation();
|
||||
};
|
||||
const interceptOverlayPointerEvent = (event: PointerEvent) => {
|
||||
if (interceptedPointers.has(event.pointerId)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const interceptOverlayPointerEnd = (event: PointerEvent) => {
|
||||
overlayPointers.delete(event.pointerId);
|
||||
if (interceptedPointers.delete(event.pointerId)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
node.addEventListener('pointerdown', interceptOverlayPointerDown, { capture: true, signal });
|
||||
node.addEventListener('pointermove', interceptOverlayPointerEvent, { capture: true, signal });
|
||||
node.addEventListener('pointerup', interceptOverlayPointerEnd, { capture: true, signal });
|
||||
node.addEventListener('pointerleave', interceptOverlayPointerEnd, { capture: true, signal });
|
||||
|
||||
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
// Touch event interception for overlay touches or split gestures (pinch across container boundary).
|
||||
// Once intercepted, stays intercepted until all fingers are lifted.
|
||||
let touchGestureIntercepted = false;
|
||||
const interceptOverlayTouchEvent = (event: Event) => {
|
||||
if (touchGestureIntercepted) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const { touches, targetTouches } = asTouchEvent(event);
|
||||
if (touches && targetTouches) {
|
||||
if (touches.length > targetTouches.length) {
|
||||
touchGestureIntercepted = true;
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
for (const touch of touches) {
|
||||
if (isOverlayAtPoint(touch.clientX, touch.clientY)) {
|
||||
touchGestureIntercepted = true;
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (isOverlayEvent(event)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
const resetTouchGesture = (event: Event) => {
|
||||
const { touches } = asTouchEvent(event);
|
||||
if (touches.length === 0) {
|
||||
touchGestureIntercepted = false;
|
||||
}
|
||||
};
|
||||
node.addEventListener('touchstart', interceptOverlayTouchEvent, { capture: true, signal });
|
||||
node.addEventListener('touchmove', interceptOverlayTouchEvent, { capture: true, signal });
|
||||
node.addEventListener('touchend', resetTouchGesture, { capture: true, signal });
|
||||
|
||||
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
|
||||
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
|
||||
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
|
||||
// Wheel and dblclick interception on overlay elements.
|
||||
// Dblclick also intercepted for all touch double-taps (Safari fires synthetic dblclick
|
||||
// on double-tap, which conflicts with zoom-image's touch zoom handler).
|
||||
let lastPointerWasTouch = false;
|
||||
const trackPointerType = (event: PointerEvent) => {
|
||||
lastPointerWasTouch = event.pointerType === 'touch';
|
||||
};
|
||||
const suppressTouchDblClick = (event: MouseEvent) => {
|
||||
if (lastPointerWasTouch) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
node.addEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
node.addEventListener('pointerdown', (event) => (lastPointerWasTouch = event.pointerType === 'touch'), {
|
||||
capture: true,
|
||||
signal,
|
||||
});
|
||||
node.addEventListener(
|
||||
'wheel',
|
||||
(event) => {
|
||||
if (isOverlayEvent(event)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
node.addEventListener(
|
||||
'dblclick',
|
||||
(event) => {
|
||||
if (lastPointerWasTouch || isOverlayEvent(event)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
{ capture: true, signal },
|
||||
);
|
||||
|
||||
// Allow zoomed content to render outside the container bounds
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = 'transform';
|
||||
}
|
||||
node.style.overflow = 'visible';
|
||||
// Prevent browser handling of touch gestures so zoom-image can manage them
|
||||
node.style.touchAction = 'none';
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
update(newOptions?: { zoomTarget?: HTMLElement }) {
|
||||
options = newOptions;
|
||||
if (newOptions?.zoomTarget !== undefined) {
|
||||
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
controller.abort();
|
||||
if (options?.zoomTarget) {
|
||||
options.zoomTarget.style.willChange = '';
|
||||
}
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
|
||||
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
@@ -17,15 +18,18 @@
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
container: Size;
|
||||
showLetterboxes?: boolean;
|
||||
transitionName?: string | null | undefined;
|
||||
letterboxTransitionName?: string | undefined;
|
||||
imageClass?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
imgNaturalSize?: Size;
|
||||
imgScaledSize?: Size;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
@@ -34,10 +38,18 @@
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgNaturalSize = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgScaledSize = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
showLetterboxes = true,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
imageClass,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
@@ -101,9 +113,21 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
$effect(() => {
|
||||
imgNaturalSize = imageDimensions;
|
||||
});
|
||||
|
||||
const scaledDimensions = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return scaleFn(imageDimensions, container);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
imgScaledSize = scaledDimensions;
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
@@ -149,81 +173,76 @@
|
||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||
});
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
<div class="relative h-full w-full overflow-hidden" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
|
||||
<Letterboxes {letterboxTransitionName} show={showLetterboxes} {scaledDimensions} {container} />
|
||||
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
class={['absolute inset-0 pointer-events-none', imageClass]}
|
||||
style:left
|
||||
style:top
|
||||
style:width
|
||||
style:height
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
>
|
||||
<div class="absolute" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||
@@ -17,15 +18,19 @@
|
||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { isPhotosRoute } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
@@ -184,7 +189,21 @@
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
|
||||
onClick={async () => {
|
||||
const assetId = stack?.primaryAssetId ?? asset.id;
|
||||
if (isPhotosRoute(page.route.id) && viewTransitionManager.isSupported()) {
|
||||
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||
eventManager.emit('ViewerCloseTransition', { id: assetId });
|
||||
await transitionReady;
|
||||
await goto(Route.photos({ at: assetId }));
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToTimeline(assetId, {
|
||||
types: ['timeline'],
|
||||
prepareOldSnapshot: () => eventManager.emit('ViewerOpenTransition'),
|
||||
});
|
||||
}}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<script module lang="ts">
|
||||
const useSplitNavTransitions =
|
||||
typeof document !== 'undefined' &&
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--immich-split-viewer-nav').trim() === 'enabled';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
@@ -13,6 +19,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -27,6 +34,7 @@
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { crossfadeViewerContent, removeCrossfadeOverlay } from '$lib/utils/transition-utils';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAssetInfo,
|
||||
@@ -40,7 +48,7 @@
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
@@ -59,7 +67,7 @@
|
||||
previousAsset?: AssetResponseDto;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
cursor: AssetCursor;
|
||||
showNavigation?: boolean;
|
||||
withStacked?: boolean;
|
||||
@@ -72,7 +80,7 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onClose?: (asset: AssetResponseDto) => void;
|
||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
cursor,
|
||||
@@ -89,13 +97,14 @@
|
||||
onRandom,
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowRepeat,
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
@@ -109,6 +118,10 @@
|
||||
let sharedLink = getSharedLink();
|
||||
let fullscreenElement = $state<Element>();
|
||||
|
||||
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
|
||||
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
|
||||
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
|
||||
@@ -142,46 +155,58 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
||||
if (asset.id === updatedAsset.id) {
|
||||
cursor = { ...cursor, current: updatedAsset };
|
||||
}
|
||||
};
|
||||
let transitionName = $state<string | undefined>('hero');
|
||||
let letterboxTransitionName = $state<string | undefined>(undefined);
|
||||
let detailPanelTransitionName = $state<string | undefined>(undefined);
|
||||
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
const addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
transitionName = 'hero';
|
||||
};
|
||||
|
||||
unsubscribes.push(
|
||||
eventManager.on({
|
||||
ViewerOpenTransition: addInfoTransition,
|
||||
ViewerCloseTransition: addInfoTransition,
|
||||
}),
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
isFaceEditMode.value = false;
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose?.(asset);
|
||||
transitionName = 'hero';
|
||||
const id = stack?.primaryAssetId ?? asset.id;
|
||||
onClose?.({ ...asset, id });
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
@@ -193,65 +218,132 @@
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const getTransitionName = (kind: 'old' | 'new', direction: string | null | undefined) => {
|
||||
if (direction === 'previous' || direction === 'next') {
|
||||
return useSplitNavTransitions ? `${direction}-${kind}` : direction;
|
||||
}
|
||||
return direction ?? undefined;
|
||||
};
|
||||
|
||||
const clearTransitionNames = () => {
|
||||
detailPanelTransitionName = undefined;
|
||||
transitionName = undefined;
|
||||
letterboxTransitionName = undefined;
|
||||
};
|
||||
|
||||
const startTransition = async (
|
||||
types: string[],
|
||||
targetTransition: string | null,
|
||||
navigateFn: () => Promise<boolean>,
|
||||
) => {
|
||||
const oldName = getTransitionName('old', targetTransition);
|
||||
const newName = getTransitionName('new', targetTransition);
|
||||
|
||||
let result = false;
|
||||
|
||||
await viewTransitionManager.startTransition({
|
||||
types,
|
||||
prepareOldSnapshot: () => {
|
||||
transitionName = oldName;
|
||||
letterboxTransitionName = targetTransition ? `${targetTransition}-old` : undefined;
|
||||
detailPanelTransitionName = 'detail-panel';
|
||||
},
|
||||
performUpdate: async (signal) => {
|
||||
const ready = eventManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||
result = await navigateFn();
|
||||
await ready;
|
||||
},
|
||||
prepareNewSnapshot: () => {
|
||||
transitionName = newName;
|
||||
letterboxTransitionName = targetTransition ? `${targetTransition}-new` : undefined;
|
||||
},
|
||||
onFinished: clearTransitionNames,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
const skipped = viewTransitionManager.skipTransitions();
|
||||
const canTransition = viewTransitionManager.isSupported() && !skipped && !skipTransition;
|
||||
|
||||
let navigate: () => Promise<boolean>;
|
||||
let types: string[];
|
||||
let targetTransition: string | null;
|
||||
|
||||
if (slideShowPlaying && slideShowShuffle) {
|
||||
navigate = async () => {
|
||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!next) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
next = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
types = ['slideshow'];
|
||||
targetTransition = null;
|
||||
} else {
|
||||
navigate = async () => {
|
||||
const target = order === 'previous' ? previousAsset : nextAsset;
|
||||
return navigateToAsset(target);
|
||||
};
|
||||
types = slideShowPlaying ? ['slideshow'] : ['viewer-nav'];
|
||||
targetTransition = slideShowPlaying ? null : order;
|
||||
}
|
||||
|
||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
||||
|
||||
if (!slideShowPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
let navigating = $state(false);
|
||||
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
if (slideShowPlaying) {
|
||||
order = slideShowAscending ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
if (isShuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
navigating = true;
|
||||
void tracker.invoke(
|
||||
() => completeNavigation(order, skipTransition),
|
||||
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||
() => {
|
||||
navigating = false;
|
||||
eventManager.emit('ViewerAfterNavigate');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Slide show mode
|
||||
*/
|
||||
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
@@ -276,10 +368,11 @@
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
if (!document.fullscreenElement) {
|
||||
return;
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||
} finally {
|
||||
@@ -288,8 +381,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
const handleStackedAssetMouseEnter = (stackedAsset: AssetResponseDto) => {
|
||||
if ((previewStackedAsset ?? cursor.current).id === stackedAsset.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFaceEditMode.value = false;
|
||||
void crossfadeViewerContent(() => {
|
||||
previewStackedAsset = stackedAsset;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStackSlideshowMouseLeave = () => {
|
||||
if (!previewStackedAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeCrossfadeOverlay();
|
||||
previewStackedAsset = undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
@@ -358,15 +467,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
const refreshOcr = async () => {
|
||||
ocrManager.clear();
|
||||
if (sharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
await refreshOcr();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
@@ -375,21 +487,36 @@
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
previewStackedAsset;
|
||||
untrack(() => ocrManager.clear());
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastCursor) {
|
||||
previewStackedAsset = undefined;
|
||||
ocrManager.showOverlay = false;
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||
lastCursor = cursor;
|
||||
return;
|
||||
}
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
}
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||
if (asset.id === update.id) {
|
||||
cursor = { ...cursor, current: update };
|
||||
}
|
||||
};
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
@@ -460,13 +587,17 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||
class:invisible={$invisible}
|
||||
data-navigating={navigating || undefined}
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -477,7 +608,7 @@
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onClose={onClose ? () => onClose(asset) : undefined}
|
||||
onClose={onClose ? closeViewer : undefined}
|
||||
{playOriginalVideo}
|
||||
{setPlayOriginalVideo}
|
||||
/>
|
||||
@@ -498,16 +629,21 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<div
|
||||
data-test-id="previous-asset"
|
||||
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
|
||||
style:view-transition-name="exclude-leftbutton"
|
||||
>
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
asset={previewStackedAsset!}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
@@ -520,6 +656,7 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
cacheKey={asset.thumbhash}
|
||||
@@ -531,13 +668,20 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer {asset} />
|
||||
<ImagePanoramaViewer {asset} {transitionName} {letterboxTransitionName} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
<PhotoViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
@@ -571,15 +715,20 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<div
|
||||
data-test-id="next-asset"
|
||||
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
|
||||
style:view-transition-name="exclude-rightbutton"
|
||||
>
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
@@ -595,9 +744,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||
{#if stack && withStacked && !assetViewerManager.isShowEditor && $slideshowState === SlideshowState.None}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
id="stack-slideshow"
|
||||
class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none"
|
||||
onmouseleave={handleStackSlideshowMouseLeave}
|
||||
>
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
@@ -610,10 +764,12 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
removeCrossfadeOverlay();
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
isFaceEditMode.value = false;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
onMouseEvent={({ isMouseOver }) => isMouseOver && handleStackedAssetMouseEnter(stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
import { timeToLoadTheMap } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
@@ -49,15 +50,15 @@
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
};
|
||||
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
let showEditFaces = $derived(isEditFacesPanelOpen.value);
|
||||
let isOwner = $derived($user?.id === asset.ownerId);
|
||||
let people = $derived(asset.people || []);
|
||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
@@ -106,7 +107,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
showEditFaces = false;
|
||||
isEditFacesPanelOpen.value = false;
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
@@ -122,7 +123,8 @@
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
showEditFaces = false;
|
||||
eventManager.emit('AssetUpdate', asset);
|
||||
isEditFacesPanelOpen.value = false;
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
@@ -219,7 +221,7 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (showEditFaces = true)}
|
||||
onclick={() => (isEditFacesPanelOpen.value = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -228,13 +230,14 @@
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person, index (person.id)}
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
|
||||
<a
|
||||
class="w-22"
|
||||
class="group w-22 outline-none"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
onpointerover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
@@ -246,6 +249,8 @@
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
hidden={person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
@@ -574,7 +579,7 @@
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => (showEditFaces = false)}
|
||||
onClose={() => (isEditFacesPanelOpen.value = false)}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -74,6 +75,8 @@
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full select-none transition-transform motion-reduce:transition-none"
|
||||
style:transform={imageTransform}
|
||||
onload={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
onerror={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -12,17 +14,19 @@
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
}
|
||||
};
|
||||
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
let faceRect: Rect | undefined = $state();
|
||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||
@@ -32,6 +36,9 @@
|
||||
|
||||
let searchTerm = $state('');
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
let userMovedRect = false;
|
||||
let previousMetrics: ResizeContext | null = null;
|
||||
let panModifierHeld = $state(false);
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@@ -53,11 +60,12 @@
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
if (!canvasEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas = new Canvas(canvasEl);
|
||||
canvas = new Canvas(canvasEl, { width: containerWidth, height: containerHeight });
|
||||
canvas.selection = false;
|
||||
configureControlStyle();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
@@ -75,66 +83,103 @@
|
||||
|
||||
canvas.add(faceRect);
|
||||
canvas.setActiveObject(faceRect);
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
setupCanvas();
|
||||
await getPeople();
|
||||
onMount(() => {
|
||||
void getPeople();
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerWidth - contentWidth) / 2,
|
||||
offsetY: (containerHeight - contentHeight) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
|
||||
faceRect.set({
|
||||
top: offsetY + 200,
|
||||
left: offsetX + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
const upperCanvas = canvas.upperCanvasEl;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
if (!faceRect) {
|
||||
const stopIfOnTarget = (event: PointerEvent) => {
|
||||
if (canvas?.findTarget(event).target) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
if (canvas.findTarget(event).target) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (faceRect) {
|
||||
event.stopPropagation();
|
||||
const pointer = canvas.getScenePoint(event);
|
||||
faceRect.set({ left: pointer.x, top: pointer.y });
|
||||
faceRect.setCoords();
|
||||
userMovedRect = true;
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
}
|
||||
};
|
||||
|
||||
upperCanvas.addEventListener('pointerdown', handlePointerDown, { signal });
|
||||
upperCanvas.addEventListener('pointermove', stopIfOnTarget, { signal });
|
||||
upperCanvas.addEventListener('pointerup', stopIfOnTarget, { signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
|
||||
});
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
|
||||
faceRect.set({
|
||||
top: offsetY + contentHeight / 2 - 56,
|
||||
left: offsetX + contentWidth / 2 - 56,
|
||||
});
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||
|
||||
if (contentWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
|
||||
const isFirstRun = previousMetrics === null;
|
||||
|
||||
if (isFirstRun && !canvas) {
|
||||
setupCanvas();
|
||||
}
|
||||
|
||||
if (!canvas || !faceRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFirstRun) {
|
||||
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||
}
|
||||
|
||||
if (!isFirstRun && userMovedRect && previousMetrics) {
|
||||
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
|
||||
} else {
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
}
|
||||
});
|
||||
|
||||
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
|
||||
const faceBox = faceRect.getBoundingRect();
|
||||
return !(
|
||||
0 > faceBox.left + faceBox.width ||
|
||||
0 > faceBox.top + faceBox.height ||
|
||||
canvas.width < faceBox.left ||
|
||||
canvas.height < faceBox.top
|
||||
);
|
||||
};
|
||||
faceRect.setCoords();
|
||||
previousMetrics = { contentWidth, offsetX, offsetY };
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
@@ -164,11 +209,15 @@
|
||||
const gap = 15;
|
||||
const padding = faceRect.padding ?? 0;
|
||||
const rawBox = faceRect.getBoundingRect();
|
||||
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
|
||||
return;
|
||||
}
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const faceBox = {
|
||||
left: rawBox.left - padding,
|
||||
top: rawBox.top - padding,
|
||||
width: rawBox.width + padding * 2,
|
||||
height: rawBox.height + padding * 2,
|
||||
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||
top: (rawBox.top - padding) * currentZoom + currentPositionY,
|
||||
width: (rawBox.width + padding * 2) * currentZoom,
|
||||
height: (rawBox.height + padding * 2) * currentZoom,
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
@@ -178,20 +227,21 @@
|
||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
const selectorBottom = position.top + selectorHeight;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
|
||||
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
||||
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const overlapX = Math.max(
|
||||
0,
|
||||
Math.min(position.left + selectorWidth, faceRight) - Math.max(position.left, faceBox.left),
|
||||
);
|
||||
const overlapY = Math.max(
|
||||
0,
|
||||
Math.min(position.top + selectorHeight, faceBottom) - Math.max(position.top, faceBox.top),
|
||||
);
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
|
||||
const positions = [
|
||||
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||
@@ -213,45 +263,139 @@
|
||||
}
|
||||
}
|
||||
|
||||
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
||||
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
||||
const containerRect = containerEl?.getBoundingClientRect();
|
||||
const offsetTop = containerRect?.top ?? 0;
|
||||
const offsetLeft = containerRect?.left ?? 0;
|
||||
faceSelectorEl.style.top = `${bestPosition.top + offsetTop}px`;
|
||||
faceSelectorEl.style.left = `${bestPosition.left + offsetLeft}px`;
|
||||
scrollableListEl.style.height = `${listHeight}px`;
|
||||
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
||||
faceBoxPosition = faceBox;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const rect = faceRect;
|
||||
if (rect) {
|
||||
rect.on('moving', positionFaceSelector);
|
||||
rect.on('scaling', positionFaceSelector);
|
||||
const onUserMove = () => {
|
||||
userMovedRect = true;
|
||||
positionFaceSelector();
|
||||
};
|
||||
rect.on('moving', onUserMove);
|
||||
rect.on('scaling', onUserMove);
|
||||
return () => {
|
||||
rect.off('moving', positionFaceSelector);
|
||||
rect.off('scaling', positionFaceSelector);
|
||||
rect.off('moving', onUserMove);
|
||||
rect.off('scaling', onUserMove);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
const panModifierKey = isMac ? 'Meta' : 'Control';
|
||||
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
|
||||
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
const element = containerEl;
|
||||
const parent = element.parentElement;
|
||||
|
||||
const activate = () => {
|
||||
panModifierHeld = true;
|
||||
element.style.pointerEvents = 'none';
|
||||
if (parent) {
|
||||
parent.style.cursor = 'move';
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = () => {
|
||||
panModifierHeld = false;
|
||||
element.style.pointerEvents = '';
|
||||
if (parent) {
|
||||
parent.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
activate();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
deactivate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', deactivate);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', deactivate);
|
||||
deactivate();
|
||||
};
|
||||
});
|
||||
|
||||
const trapEvents = (node: HTMLElement) => {
|
||||
const stop = (e: Event) => e.stopPropagation();
|
||||
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
|
||||
for (const type of eventTypes) {
|
||||
node.addEventListener(type, stop);
|
||||
}
|
||||
|
||||
// Move to body so the selector isn't affected by the zoom transform on the container
|
||||
document.body.append(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
for (const type of eventTypes) {
|
||||
node.removeEventListener(type, stop);
|
||||
}
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || !htmlElement) {
|
||||
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const scaledWidth = faceRect.getScaledWidth();
|
||||
const scaledHeight = faceRect.getScaledHeight();
|
||||
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
const imageRect = mapContentRectToNatural(
|
||||
{
|
||||
left: faceRect.left - scaledWidth / 2,
|
||||
top: faceRect.top - scaledHeight / 2,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
},
|
||||
imageContentMetrics,
|
||||
imageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
x: Math.floor(imageRect.left),
|
||||
y: Math.floor(imageRect.top),
|
||||
width: Math.floor(imageRect.width),
|
||||
height: Math.floor(imageRect.height),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -282,10 +426,9 @@
|
||||
});
|
||||
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
isFaceEditMode.value = false;
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
isFaceEditMode.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -294,6 +437,7 @@
|
||||
|
||||
<div
|
||||
id="face-editor-data"
|
||||
bind:this={containerEl}
|
||||
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||
data-face-left={faceBoxPosition.left}
|
||||
data-face-top={faceBoxPosition.top}
|
||||
@@ -305,7 +449,9 @@
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
class="fixed z-20 w-[min(200px,45vw)] min-w-48 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||
use:trapEvents
|
||||
onwheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||
|
||||
@@ -346,4 +492,15 @@
|
||||
|
||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||
</div>
|
||||
|
||||
{#if isZoomed && !panModifierHeld}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
|
||||
>
|
||||
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
|
||||
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
let { asset }: Props = $props();
|
||||
let { transitionName, letterboxTransitionName, asset }: Props = $props();
|
||||
|
||||
const assetId = $derived(asset.id);
|
||||
|
||||
@@ -20,11 +21,16 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
panorama={data}
|
||||
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
letterboxTransitionName?: string | undefined;
|
||||
show?: boolean;
|
||||
scaledDimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { letterboxTransitionName, show = true, scaledDimensions, container }: Props = $props();
|
||||
|
||||
const shouldShowLetterboxes = $derived(show && !!letterboxTransitionName);
|
||||
|
||||
const letterboxes = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const horizontalOffset = (container.width - width) / 2;
|
||||
const verticalOffset = (container.height - height) / 2;
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'letterbox-left',
|
||||
width: horizontalOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-right',
|
||||
width: horizontalOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: container.width - horizontalOffset + 'px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-top',
|
||||
width: width + 'px',
|
||||
height: verticalOffset + 'px',
|
||||
left: horizontalOffset + 'px',
|
||||
top: '0px',
|
||||
},
|
||||
{
|
||||
name: 'letterbox-bottom',
|
||||
width: width + 'px',
|
||||
height: verticalOffset + 'px',
|
||||
left: horizontalOffset + 'px',
|
||||
top: container.height - verticalOffset + 'px',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shouldShowLetterboxes}
|
||||
{#each letterboxes as box (box.name)}
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={box.name}
|
||||
style:left={box.left}
|
||||
style:top={box.top}
|
||||
style:width={box.width}
|
||||
style:height={box.height}
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import type { OcrBox } from '$lib/utils/ocr-utils';
|
||||
import { calculateBoundingBoxMatrix, calculateFittedFontSize } from '$lib/utils/ocr-utils';
|
||||
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
let { ocrBox }: Props = $props();
|
||||
|
||||
const isTouch = $derived(mediaQueryManager.pointerCoarse);
|
||||
const dimensions = $derived(calculateBoundingBoxMatrix(ocrBox.points));
|
||||
|
||||
const transform = $derived(`matrix3d(${dimensions.matrix.join(',')})`);
|
||||
@@ -15,13 +17,23 @@
|
||||
calculateFittedFontSize(ocrBox.text, dimensions.width, dimensions.height, ocrBox.verticalMode) + 'px',
|
||||
);
|
||||
|
||||
const handleSelectStart = (event: Event) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
const selection = globalThis.getSelection();
|
||||
if (selection) {
|
||||
selection.selectAllChildren(target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const verticalStyle = $derived.by(() => {
|
||||
switch (ocrBox.verticalMode) {
|
||||
case 'cjk': {
|
||||
return ' writing-mode: vertical-rl;';
|
||||
return 'writing-mode: vertical-rl;';
|
||||
}
|
||||
case 'rotated': {
|
||||
return ' writing-mode: vertical-rl; text-orientation: sideways;';
|
||||
return 'writing-mode: vertical-rl; text-orientation: sideways;';
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
@@ -30,17 +42,23 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="absolute left-0 top-0">
|
||||
<div
|
||||
class="absolute flex items-center justify-center text-transparent border-2 border-blue-500 bg-blue-500/10 pointer-events-auto cursor-text select-text transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3 focus:z-1 focus:text-white focus:bg-black/60 focus:border-blue-600 focus:border-3 focus:outline-none {ocrBox.verticalMode ===
|
||||
'none'
|
||||
? 'px-2 py-1 whitespace-nowrap'
|
||||
: 'px-1 py-2'}"
|
||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;{verticalStyle}"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={ocrBox.text}
|
||||
>
|
||||
{ocrBox.text}
|
||||
</div>
|
||||
<div
|
||||
class={[
|
||||
'absolute left-0 top-0 flex items-center justify-center',
|
||||
'border-2 border-blue-500 pointer-events-auto cursor-text',
|
||||
'focus:z-1 focus:border-blue-600 focus:border-3 focus:outline-none',
|
||||
isTouch
|
||||
? 'text-white bg-black/60 select-all'
|
||||
: 'select-text text-transparent bg-blue-500/10 transition-colors hover:z-1 hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3',
|
||||
ocrBox.verticalMode === 'none' ? 'px-2 py-1 whitespace-nowrap' : 'px-1 py-2',
|
||||
]}
|
||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0; touch-action: none; {verticalStyle}"
|
||||
data-testid="ocr-box"
|
||||
data-overlay-interactive
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={ocrBox.text}
|
||||
onselectstart={isTouch ? handleSelectStart : undefined}
|
||||
>
|
||||
{ocrBox.text}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
@@ -41,6 +43,8 @@
|
||||
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
@@ -48,11 +52,21 @@
|
||||
navbar?: boolean;
|
||||
};
|
||||
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
let {
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
plugins = [],
|
||||
navbar = false,
|
||||
}: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
|
||||
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
|
||||
|
||||
let animationInProgress: { cancel: () => void } | undefined;
|
||||
let previousFaces: Faces[] = [];
|
||||
|
||||
@@ -128,10 +142,8 @@
|
||||
}
|
||||
|
||||
const boxes = getOcrBoundingBoxes(ocrData, {
|
||||
contentWidth: viewer.state.textureData.panoData.croppedWidth,
|
||||
contentHeight: viewer.state.textureData.panoData.croppedHeight,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
width: viewer.state.textureData.panoData.croppedWidth,
|
||||
height: viewer.state.textureData.panoData.croppedHeight,
|
||||
});
|
||||
|
||||
for (const [index, box] of boxes.entries()) {
|
||||
@@ -212,6 +224,7 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener('ready', () => eventManager.emit('ViewerOpenTransitionReady'), { once: true });
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
@@ -256,7 +269,15 @@
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
<div
|
||||
id="sphere"
|
||||
class="h-full w-full h-dvh w-dvw mb-0"
|
||||
bind:this={container}
|
||||
style:view-transition-name={transitionName}
|
||||
></div>
|
||||
|
||||
<!-- Zero-sized letterboxes for view transitions from/to regular photos -->
|
||||
<Letterboxes {letterboxTransitionName} scaledDimensions={fullscreenDimensions} container={fullscreenDimensions} />
|
||||
|
||||
<style>
|
||||
/* Reset the default tooltip styling */
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -25,18 +26,30 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
transitionName?: string;
|
||||
letterboxTransitionName?: string;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
sharedLink,
|
||||
transitionName,
|
||||
letterboxTransitionName,
|
||||
onError,
|
||||
onSwipe,
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const objectFit = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
||||
);
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
@@ -67,23 +80,12 @@
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight });
|
||||
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
|
||||
|
||||
return {
|
||||
contentWidth: scaled.width,
|
||||
contentHeight: scaled.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
@@ -105,12 +107,6 @@
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
||||
onZoom();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO move to action + command palette
|
||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||
if (globalThis.getSelection()?.type === 'Range') {
|
||||
@@ -151,48 +147,26 @@
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
const map = new Map<Faces, string | undefined>();
|
||||
for (const person of asset.people ?? []) {
|
||||
for (const face of person.faces ?? []) {
|
||||
map.set(face, person.name);
|
||||
}
|
||||
}
|
||||
for (const face of asset.unassignedFaces ?? []) {
|
||||
map.set(face, undefined);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Array needed for indexed access in the template (faces[index])
|
||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||
|
||||
const handleImageMouseMove = (event: MouseEvent) => {
|
||||
$boundingBoxesArray = [];
|
||||
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const contentOffsetX = (container.width - scaled.width) / 2;
|
||||
const contentOffsetY = (container.height - scaled.height) / 2;
|
||||
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
|
||||
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
|
||||
|
||||
const faceBoxes = getBoundingBox(faces, overlayMetrics);
|
||||
|
||||
for (const [index, box] of faceBoxes.entries()) {
|
||||
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
|
||||
$boundingBoxesArray.push(faces[index]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageMouseLeave = () => {
|
||||
$boundingBoxesArray = [];
|
||||
};
|
||||
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} />
|
||||
@@ -213,26 +187,30 @@
|
||||
bind:clientHeight={containerHeight}
|
||||
role="presentation"
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
|
||||
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{objectFit}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
eventManager.emit('ViewerOpenTransitionReady');
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:imgNaturalSize={imageDimensions}
|
||||
bind:imgScaledSize={scaledDimensions}
|
||||
bind:ref={adaptiveImage}
|
||||
{transitionName}
|
||||
{letterboxTransitionName}
|
||||
showLetterboxes={!blurredSlideshow}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
@@ -243,20 +221,40 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
{#if !isFaceEditMode.value}
|
||||
{#each boundingBoxes as boundingbox, index (boundingbox.id)}
|
||||
{@const face = faces[index]}
|
||||
{@const name = faceToNameMap.get(face)}
|
||||
{#if name !== undefined || isEditFacesPanelOpen.value}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute pointer-events-auto outline-none rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
aria-label="{$t('person')}: {name ?? $t('unknown')}"
|
||||
onpointerenter={() => ($boundingBoxesArray = [face])}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each activeBoundingBoxes as boundingbox (boundingbox.id)}
|
||||
{@const face = faces.find((f) => f.id === boundingbox.id)}
|
||||
{@const name = face ? faceToNameMap.get(face) : undefined}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||
<div
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||
boundingbox.width}px; transform: translateX(-100%);"
|
||||
>
|
||||
{faceToNameMap.get($boundingBoxesArray[index])}
|
||||
</div>
|
||||
{/if}
|
||||
>
|
||||
{#if name}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
||||
style="top: {boundingbox.height + 4}px; right: 0;"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
@@ -266,6 +264,6 @@
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import {
|
||||
autoPlayVideo,
|
||||
@@ -11,14 +12,17 @@
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
assetId: string;
|
||||
imageSize: Size;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
@@ -27,10 +31,12 @@
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
assetId,
|
||||
imageSize,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
@@ -58,7 +64,6 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
hasFocused = false;
|
||||
videoPlayer?.load();
|
||||
@@ -139,6 +144,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
@@ -147,6 +153,7 @@
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
onloadedmetadata={() => eventManager.emit('ViewerOpenTransitionReady')}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
@@ -173,7 +180,7 @@
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
<FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
const { asset }: Props = $props();
|
||||
const { asset, transitionName }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -19,11 +19,12 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div class="flex h-full select-none place-content-center place-items-center">
|
||||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
assetId?: string;
|
||||
projectionType: string | null | undefined;
|
||||
@@ -16,9 +17,10 @@
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
assetId,
|
||||
projectionType,
|
||||
@@ -36,12 +38,14 @@
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {asset} />
|
||||
<VideoPanoramaViewer {transitionName} {asset} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
circle?: boolean;
|
||||
hidden?: boolean;
|
||||
border?: boolean;
|
||||
highlighted?: boolean;
|
||||
hiddenIconClass?: string;
|
||||
class?: ClassValue;
|
||||
brokenAssetClass?: ClassValue;
|
||||
@@ -34,6 +35,7 @@
|
||||
circle = false,
|
||||
hidden = false,
|
||||
border = false,
|
||||
highlighted = false,
|
||||
hiddenIconClass = 'text-white',
|
||||
onComplete = undefined,
|
||||
class: imageClass = '',
|
||||
@@ -83,6 +85,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if highlighted}
|
||||
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<!-- TODO fix `title` type -->
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
assetId: string;
|
||||
assetType: AssetTypeEnum;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const thumbnailWidth = '90px';
|
||||
const focusHighlightClass =
|
||||
'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary';
|
||||
|
||||
async function loadPeople() {
|
||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||
@@ -226,14 +228,16 @@
|
||||
{:else}
|
||||
{#each peopleWithFaces as face, index (face.id)}
|
||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
|
||||
<div class="relative h-29 w-24">
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
||||
data-testid="face-thumbnail"
|
||||
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
|
||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
@@ -245,6 +249,8 @@
|
||||
title={$t('new_person')}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
@@ -259,6 +265,8 @@
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else if face.person}
|
||||
<ImageThumbnail
|
||||
@@ -270,6 +278,8 @@
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:else}
|
||||
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
||||
@@ -281,6 +291,8 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{:then data}
|
||||
<ImageThumbnail
|
||||
@@ -291,6 +303,8 @@
|
||||
title={$t('face_unassigned')}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
highlighted={isHighlighted}
|
||||
class={focusHighlightClass}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -44,12 +45,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -58,13 +64,15 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if sidebar}
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
onImageLoad: () => void;
|
||||
transitionName?: string;
|
||||
}
|
||||
|
||||
const { asset, onImageLoad }: Props = $props();
|
||||
const { asset, onImageLoad, transitionName }: Props = $props();
|
||||
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
@@ -52,6 +53,7 @@
|
||||
src={assetFileUrl}
|
||||
alt={$getAltText(asset)}
|
||||
draggable="false"
|
||||
style:view-transition-name={transitionName}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
||||
@@ -226,6 +227,20 @@
|
||||
handlePromiseError(handleAction('galleryInView', 'pause'));
|
||||
};
|
||||
|
||||
let memoryTransitionName = $state<string | undefined>(undefined);
|
||||
|
||||
const viewInTimeline = (assetId: string) => {
|
||||
navigateToTimeline(assetId, {
|
||||
types: ['memory-enter'],
|
||||
prepareOldSnapshot: () => {
|
||||
memoryTransitionName = 'hero';
|
||||
},
|
||||
onFinished: () => {
|
||||
memoryTransitionName = undefined;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGalleryScrollsOutOfView = () => {
|
||||
galleryInView = false;
|
||||
// only call play after the first page load. When page first loads the gallery will not be visible
|
||||
@@ -505,7 +520,11 @@
|
||||
videoViewerVolume={$videoViewerVolume}
|
||||
/>
|
||||
{:else}
|
||||
<MemoryPhotoViewer asset={current.asset} onImageLoad={resetAndPlay} />
|
||||
<MemoryPhotoViewer
|
||||
asset={current.asset}
|
||||
onImageLoad={resetAndPlay}
|
||||
transitionName={memoryTransitionName}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
@@ -560,6 +579,10 @@
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
onclick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
@@ -27,9 +30,11 @@
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -65,6 +70,16 @@
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||
|
||||
$effect(() => {
|
||||
if ($isViewerOpen) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.style.overflow = '';
|
||||
};
|
||||
});
|
||||
|
||||
const navigationAssets = $derived(viewerAssets ?? assets);
|
||||
|
||||
const geometry = $derived(
|
||||
@@ -107,6 +122,36 @@
|
||||
|
||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
const scrollGalleryToAsset = async (assetId: string) => {
|
||||
const index = assets.findIndex((asset) => asset.id === assetId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const assetTopPage = geometry.getTop(index) + slidingWindowOffset;
|
||||
const assetBottomPage = assetTopPage + geometry.getHeight(index);
|
||||
const currentScrollTop = document.scrollingElement?.scrollTop ?? 0;
|
||||
const visibleTop = currentScrollTop + pageHeaderOffset;
|
||||
const visibleBottom = currentScrollTop + viewport.height;
|
||||
|
||||
if (assetTopPage >= visibleTop && assetBottomPage <= visibleBottom) {
|
||||
return;
|
||||
}
|
||||
const distanceToAlignTop = Math.abs(assetTopPage - pageHeaderOffset - currentScrollTop);
|
||||
const distanceToAlignBottom = Math.abs(assetBottomPage - viewport.height - currentScrollTop);
|
||||
const newScrollTop =
|
||||
distanceToAlignTop < distanceToAlignBottom ? assetTopPage - pageHeaderOffset : assetBottomPage - viewport.height;
|
||||
if (document.scrollingElement) {
|
||||
document.scrollingElement.scrollTop = newScrollTop;
|
||||
}
|
||||
updateSlidingWindow();
|
||||
await tick();
|
||||
};
|
||||
|
||||
const scrollToAndFocusAsset = async (assetId: string) => {
|
||||
await scrollGalleryToAsset(assetId);
|
||||
focusAsset(assetId);
|
||||
};
|
||||
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
|
||||
let lastIntersectedHeight = 0;
|
||||
@@ -356,6 +401,63 @@
|
||||
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
|
||||
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
|
||||
});
|
||||
|
||||
let toViewerTransitionId = $state<string | null>(null);
|
||||
let toGalleryTransitionId = $state<string | null>(null);
|
||||
const transitionTargetId = $derived(toViewerTransitionId ?? toGalleryTransitionId);
|
||||
|
||||
const handleThumbnailClick = (asset: AssetResponseDto, currentAsset: TimelineAsset) => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(currentAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
const doNavigate = () => void navigateToAsset(asset);
|
||||
|
||||
if (!viewTransitionManager.isSupported()) {
|
||||
doNavigate();
|
||||
return;
|
||||
}
|
||||
|
||||
startViewerTransition(asset.id, doNavigate, (id) => (toViewerTransitionId = id));
|
||||
};
|
||||
|
||||
const transitionToGalleryCallback = ({ id }: { id: string }) => {
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
prepareOldSnapshot: () => {
|
||||
void scrollGalleryToAsset(id);
|
||||
},
|
||||
performUpdate: async () => {
|
||||
eventManager.emit('ViewerCloseTransitionReady');
|
||||
toGalleryTransitionId = id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toGalleryTransitionId = null;
|
||||
focusAsset(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToGalleryCallback }));
|
||||
}
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const useTransition = viewTransitionManager.isSupported();
|
||||
if (useTransition) {
|
||||
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
||||
eventManager.emit('ViewerCloseTransition', { id: asset.id });
|
||||
await transitionReady;
|
||||
}
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
if (!useTransition) {
|
||||
await tick();
|
||||
await scrollToAndFocusAsset(asset.id);
|
||||
}
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }, { noScroll: true }));
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
@@ -375,16 +477,17 @@
|
||||
{#each assets as asset, i (asset.id + '-' + i)}
|
||||
{#if isIntersecting(i)}
|
||||
{@const currentAsset = toTimelineAsset(asset)}
|
||||
<div class="absolute" style:overflow="clip" style={getStyle(i)}>
|
||||
{@const transitionName = transitionTargetId === asset.id ? 'hero' : undefined}
|
||||
<div
|
||||
class="absolute"
|
||||
style:overflow="clip"
|
||||
style={getStyle(i)}
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={() => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(currentAsset);
|
||||
return;
|
||||
}
|
||||
void navigateToAsset(asset);
|
||||
}}
|
||||
onClick={() => handleThumbnailClick(asset, currentAsset)}
|
||||
onSelect={() => handleSelectAssets(currentAsset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
|
||||
{showArchiveIcon}
|
||||
@@ -416,10 +519,7 @@
|
||||
onAction={handleAction}
|
||||
onRandom={handleRandom}
|
||||
onAssetChange={updateCurrentAsset}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}}
|
||||
onClose={(asset) => handleClose(asset)}
|
||||
/>
|
||||
{/await}
|
||||
</Portal>
|
||||
|
||||
@@ -50,7 +50,11 @@
|
||||
|
||||
<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 relative z-10"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<div
|
||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
animationTargetAssetId?: string | null;
|
||||
suspendTransitions?: boolean;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
manager: VirtualScrollManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -26,14 +25,20 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const {
|
||||
animationTargetAssetId,
|
||||
suspendTransitions = false,
|
||||
viewerAssets,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
customThumbnailLayout,
|
||||
}: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Image grid -->
|
||||
@@ -41,11 +46,14 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
data-transition-name={transitionName}
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:inset-inline-start={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { assetsSnapshot, filterIntersecting } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount, tick, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toAssetViewerTransitionId?: string | null;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
assetInteraction: AssetInteraction;
|
||||
monthGroup: MonthGroup;
|
||||
manager: VirtualScrollManager;
|
||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
toAssetViewerTransitionId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
monthGroup,
|
||||
manager,
|
||||
onDayGroupSelect,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -37,10 +40,6 @@
|
||||
|
||||
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
};
|
||||
|
||||
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
||||
const { month, year } = dayGroup.monthGroup.yearMonth;
|
||||
const date = fromTimelinePlainDate({
|
||||
@@ -50,6 +49,32 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||
|
||||
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||
const asset = monthGroup.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
void viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
performUpdate: async () => {
|
||||
eventManager.emit('ViewerCloseTransitionReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineTransitionAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineTransitionAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on({ ViewerCloseTransition: transitionToTimelineCallback }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -93,7 +118,8 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
{animationTargetAssetId}
|
||||
suspendTransitions={monthGroup.timelineManager.suspendTransitions}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -438,7 +440,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next.focus();
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,6 +511,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
@@ -102,6 +104,7 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
@@ -209,19 +212,22 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
}
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
timelineManager.scrollTo(0);
|
||||
if (scrollTarget) {
|
||||
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||
}
|
||||
} else if (scrollTarget) {
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -239,10 +245,13 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -251,8 +260,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -260,7 +274,7 @@
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -524,6 +538,33 @@
|
||||
}
|
||||
});
|
||||
|
||||
const defaultThumbnailClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (asset: TimelineAsset, dayGroup: DayGroup) => {
|
||||
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, defaultThumbnailClick);
|
||||
} else {
|
||||
defaultThumbnailClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
startViewerTransition(asset.id, openViewer, (id) => (toAssetViewerTransitionId = id));
|
||||
};
|
||||
|
||||
const assetSelectHandler = (
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
@@ -544,19 +585,6 @@
|
||||
|
||||
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.selectedAssets.length;
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -587,6 +615,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -618,6 +647,7 @@
|
||||
id="asset-grid"
|
||||
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
data-initialized={timelineManager.isInitialized || undefined}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
@@ -666,11 +696,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -684,13 +714,7 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onClick={(asset) => handleThumbnailClick(asset, dayGroup)}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user