mirror of
https://github.com/immich-app/immich.git
synced 2026-03-23 10:34:23 -07:00
Compare commits
11 Commits
push-qslzs
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39de5e8cf | ||
|
|
0e93aa74cf | ||
|
|
e95ad9d2eb | ||
|
|
b98a227bbd | ||
|
|
2dd785e3e2 | ||
|
|
7e754125cd | ||
|
|
e2eb03d3a4 | ||
|
|
bf065a834f | ||
|
|
db79173b5b | ||
|
|
33666ccd21 | ||
|
|
be93b9040c |
143
.github/workflows/auto-close.yml
vendored
Normal file
143
.github/workflows/auto-close.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
}'
|
||||
97
.github/workflows/check-pr-template.yml
vendored
97
.github/workflows/check-pr-template.yml
vendored
@@ -1,97 +0,0 @@
|
||||
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
|
||||
}
|
||||
}'
|
||||
38
.github/workflows/close-llm-pr.yml
vendored
38
.github/workflows/close-llm-pr.yml
vendored
@@ -1,38 +0,0 @@
|
||||
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
|
||||
}
|
||||
}'
|
||||
305
.github/workflows/test.yml
vendored
305
.github/workflows/test.yml
vendored
@@ -8,99 +8,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions: {}
|
||||
|
||||
x-e2e-server-job: &e2e-server-job
|
||||
needs: [pre-job, build-e2e-images]
|
||||
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
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
|
||||
x-e2e-web-job: &e2e-web-job
|
||||
needs: [pre-job, build-e2e-images]
|
||||
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
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
|
||||
x-e2e-steps:
|
||||
token: &e2e-token-step
|
||||
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 }}
|
||||
checkout: &e2e-checkout-step
|
||||
name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
setup-pnpm: &e2e-setup-pnpm-step
|
||||
name: Setup pnpm
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
setup-node: &e2e-setup-node-step
|
||||
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'
|
||||
setup-sdk: &e2e-setup-sdk-step
|
||||
name: Run setup typescript-sdk
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./open-api/typescript-sdk
|
||||
if: ${{ !cancelled() }}
|
||||
setup-web: &e2e-setup-web-step
|
||||
name: Run setup web
|
||||
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
|
||||
working-directory: ./web
|
||||
if: ${{ !cancelled() }}
|
||||
setup-cli: &e2e-setup-cli-step
|
||||
name: Run setup cli
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
working-directory: ./cli
|
||||
if: ${{ !cancelled() }}
|
||||
install-deps: &e2e-install-deps-step
|
||||
name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: ${{ !cancelled() }}
|
||||
install-playwright: &e2e-install-playwright-step
|
||||
name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install chromium --only-shell
|
||||
if: ${{ !cancelled() }}
|
||||
download-images: &e2e-download-images-step
|
||||
name: Download Docker images
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: docker-images-${{ matrix.runner }}
|
||||
path: e2e
|
||||
load-images: &e2e-load-images-step
|
||||
name: Load Docker images
|
||||
run: docker load < docker-images.tar.gz
|
||||
docker-compose-up: &e2e-docker-compose-up-step
|
||||
name: Start Docker Compose
|
||||
run: docker compose up -d --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
capture-docker-logs: &e2e-capture-docker-logs-step
|
||||
name: Capture Docker logs
|
||||
if: always()
|
||||
run: docker compose logs --no-color > docker-compose-logs.txt
|
||||
working-directory: ./e2e
|
||||
|
||||
jobs:
|
||||
pre-job:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -490,10 +397,10 @@ jobs:
|
||||
- name: Run medium tests
|
||||
run: pnpm test:medium
|
||||
if: ${{ !cancelled() }}
|
||||
build-e2e-images:
|
||||
name: Build E2E Docker Images
|
||||
e2e-tests-server-cli:
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
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 || fromJSON(needs.pre-job.outputs.should_run).web == true }}
|
||||
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
|
||||
@@ -504,86 +411,111 @@ jobs:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- name: Build Docker images
|
||||
run: docker compose build
|
||||
- name: Save Docker images
|
||||
run: docker save immich-server:latest immich-e2e-e2e-auth-server | gzip > docker-images.tar.gz
|
||||
- name: Upload Docker images
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@05e16407c0a5492138bb38139c9d9bf067b40886 # create-workflow-token-action-v1.0.1
|
||||
with:
|
||||
name: docker-images-${{ matrix.runner }}
|
||||
path: e2e/docker-images.tar.gz
|
||||
retention-days: 1
|
||||
e2e-tests-server-cli:
|
||||
<<: *e2e-server-job
|
||||
name: End-to-End Tests (Server & CLI)
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- *e2e-setup-pnpm-step
|
||||
- *e2e-setup-node-step
|
||||
- *e2e-setup-sdk-step
|
||||
- *e2e-setup-web-step
|
||||
- *e2e-setup-cli-step
|
||||
- *e2e-install-deps-step
|
||||
- *e2e-download-images-step
|
||||
- *e2e-load-images-step
|
||||
- *e2e-docker-compose-up-step
|
||||
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: 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 --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Run e2e tests (api & cli)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test
|
||||
if: ${{ !cancelled() }}
|
||||
- *e2e-capture-docker-logs-step
|
||||
- name: Run e2e tests (maintenance)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- 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:
|
||||
<<: *e2e-server-job
|
||||
name: End-to-End Tests (Server Maintenance)
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- *e2e-setup-pnpm-step
|
||||
- *e2e-setup-node-step
|
||||
- *e2e-setup-sdk-step
|
||||
- *e2e-setup-web-step
|
||||
- *e2e-setup-cli-step
|
||||
- *e2e-install-deps-step
|
||||
- *e2e-download-images-step
|
||||
- *e2e-load-images-step
|
||||
- *e2e-docker-compose-up-step
|
||||
- name: Run e2e tests (maintenance)
|
||||
env:
|
||||
VITEST_DISABLE_DOCKER_SETUP: true
|
||||
run: pnpm test:maintenance
|
||||
if: ${{ !cancelled() }}
|
||||
- *e2e-capture-docker-logs-step
|
||||
- name: Archive Docker logs
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-server-maintenance-docker-logs-${{ matrix.runner }}
|
||||
path: e2e/docker-compose-logs.txt
|
||||
e2e-tests-web:
|
||||
<<: *e2e-web-job
|
||||
name: End-to-End Tests (Web)
|
||||
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
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- *e2e-setup-pnpm-step
|
||||
- *e2e-setup-node-step
|
||||
- *e2e-setup-sdk-step
|
||||
- *e2e-install-deps-step
|
||||
- *e2e-install-playwright-step
|
||||
- *e2e-download-images-step
|
||||
- *e2e-load-images-step
|
||||
- *e2e-docker-compose-up-step
|
||||
- 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: 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)
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
@@ -595,27 +527,6 @@ jobs:
|
||||
with:
|
||||
name: e2e-web-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- *e2e-capture-docker-logs-step
|
||||
- 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:
|
||||
<<: *e2e-web-job
|
||||
name: End-to-End Tests (Web UI)
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- *e2e-setup-pnpm-step
|
||||
- *e2e-setup-node-step
|
||||
- *e2e-setup-sdk-step
|
||||
- *e2e-install-deps-step
|
||||
- *e2e-install-playwright-step
|
||||
- *e2e-download-images-step
|
||||
- *e2e-load-images-step
|
||||
- *e2e-docker-compose-up-step
|
||||
- name: Run ui tests (web)
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
@@ -627,27 +538,6 @@ jobs:
|
||||
with:
|
||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- *e2e-capture-docker-logs-step
|
||||
- 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:
|
||||
<<: *e2e-web-job
|
||||
name: End-to-End Tests (Web Maintenance)
|
||||
steps:
|
||||
- *e2e-token-step
|
||||
- *e2e-checkout-step
|
||||
- *e2e-setup-pnpm-step
|
||||
- *e2e-setup-node-step
|
||||
- *e2e-setup-sdk-step
|
||||
- *e2e-install-deps-step
|
||||
- *e2e-install-playwright-step
|
||||
- *e2e-download-images-step
|
||||
- *e2e-load-images-step
|
||||
- *e2e-docker-compose-up-step
|
||||
- name: Run maintenance tests
|
||||
env:
|
||||
PLAYWRIGHT_DISABLE_WEBSERVER: true
|
||||
@@ -659,16 +549,19 @@ jobs:
|
||||
with:
|
||||
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
|
||||
path: e2e/playwright-report/
|
||||
- *e2e-capture-docker-logs-step
|
||||
- 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-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()
|
||||
|
||||
@@ -35,8 +35,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vitest-fetch-mock": "^0.4.0",
|
||||
"yaml": "^2.3.1"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineConfig, UserConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { src: '/src' } },
|
||||
resolve: {
|
||||
alias: { src: '/src' },
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
@@ -16,7 +18,6 @@ export default defineConfig({
|
||||
// bundle everything except for Node built-ins
|
||||
noExternal: /^(?!node:).*$/,
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
name: 'cli:unit',
|
||||
globals: true,
|
||||
|
||||
51
e2e/src/specs/web/duplicates.e2e-spec.ts
Normal file
51
e2e/src/specs/web/duplicates.e2e-spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
final person = people[index];
|
||||
|
||||
return Column(
|
||||
key: ValueKey(person.id),
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
@@ -88,6 +89,7 @@ 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,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final previousFilter = useState<SearchFilter?>(null);
|
||||
final hasRequestedSearch = useState<bool>(false);
|
||||
final dateInputFilter = useState<DateFilterInputModel?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
@@ -91,9 +92,11 @@ 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;
|
||||
}
|
||||
@@ -107,6 +110,8 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
searchPreFilter() {
|
||||
if (preFilter != null) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
filter.value = preFilter;
|
||||
textSearchController.clear();
|
||||
searchFilter(preFilter);
|
||||
|
||||
if (preFilter.location.city != null) {
|
||||
@@ -719,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filter.value.isEmpty)
|
||||
if (!hasRequestedSearch.value)
|
||||
const _SearchSuggestions()
|
||||
else
|
||||
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),
|
||||
|
||||
@@ -24,20 +24,22 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
ref.invalidate(assetViewerProvider);
|
||||
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,
|
||||
),
|
||||
);
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(context.navigateTo(const DriftSearchRoute()));
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ 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;
|
||||
|
||||
@@ -16,9 +16,15 @@ 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 menuStyle = const MenuStyle(
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
|
||||
const menuStyle = MenuStyle(
|
||||
shape: WidgetStatePropertyAll<OutlinedBorder>(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
|
||||
),
|
||||
@@ -26,11 +32,26 @@ 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,
|
||||
dropdownMenuEntries: dropdownMenuEntries,
|
||||
menuHeight: maxMenuHeight,
|
||||
dropdownMenuEntries: styledEntries,
|
||||
label: label,
|
||||
menuStyle: menuStyle,
|
||||
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
|
||||
781
pnpm-lock.yaml
generated
781
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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.0",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.27.1",
|
||||
"@sveltejs/vite-plugin-svelte": "6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@sveltejs/vite-plugin-svelte": "7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@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.1.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||
import type { AssetControlContext } from '$lib/types';
|
||||
import { getSharedLink, sleep } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetMediaSize,
|
||||
AssetTypeEnum,
|
||||
AssetVisibility,
|
||||
getAssetInfo,
|
||||
getBaseUrl,
|
||||
runAssetJobs,
|
||||
updateAsset,
|
||||
type AssetJobsDto,
|
||||
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
{
|
||||
filename: asset.originalFileName,
|
||||
id: asset.id,
|
||||
cacheKey: asset.thumbhash,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
assets.push({
|
||||
filename: motionAsset.originalFileName,
|
||||
id: asset.livePhotoVideoId,
|
||||
cacheKey: motionAsset.thumbhash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
for (const [i, { filename, id }] of assets.entries()) {
|
||||
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
|
||||
if (i !== 0) {
|
||||
// play nice with Safari
|
||||
await sleep(500);
|
||||
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
|
||||
|
||||
try {
|
||||
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
|
||||
downloadUrl(
|
||||
getBaseUrl() +
|
||||
`/assets/${id}/original` +
|
||||
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
|
||||
filename,
|
||||
);
|
||||
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_downloading', { values: { filename } }));
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createAlbumAssetSelectionStore() {
|
||||
const isAlbumAssetSelectionOpen = writable<boolean>(false);
|
||||
return {
|
||||
isAlbumAssetSelectionOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();
|
||||
@@ -80,7 +80,34 @@ function createUploadStore() {
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
|
||||
uploadAssets.update((uploadingAsset) => {
|
||||
const assetToRemove = uploadingAsset.find((a) => a.id === id);
|
||||
if (assetToRemove) {
|
||||
stats.update((stats) => {
|
||||
switch (assetToRemove.state) {
|
||||
case UploadState.DONE: {
|
||||
stats.success--;
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadState.DUPLICATED: {
|
||||
stats.duplicates--;
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadState.ERROR: {
|
||||
stats.errors--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stats.total--;
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
return uploadingAsset.filter((a) => a.id != id);
|
||||
});
|
||||
};
|
||||
|
||||
const dismissErrors = () =>
|
||||
|
||||
@@ -178,19 +178,7 @@
|
||||
|
||||
const handleFirst = () => navigateToIndex(0);
|
||||
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
|
||||
const handlePreviousShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handlePrevious();
|
||||
};
|
||||
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
|
||||
const handleNextShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handleNext();
|
||||
};
|
||||
const handleLast = () => navigateToIndex(duplicates.length - 1);
|
||||
|
||||
const navigateToIndex = async (index: number) =>
|
||||
@@ -198,10 +186,12 @@
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
|
||||
]}
|
||||
use:shortcuts={$showAssetViewer
|
||||
? []
|
||||
: [
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
|
||||
]}
|
||||
/>
|
||||
|
||||
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>
|
||||
|
||||
Reference in New Issue
Block a user