mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
feat: add futo backup surveys (#406)
This commit is contained in:
+25
-10
@@ -22,11 +22,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||
include:
|
||||
- app_name: futo-backups-survey
|
||||
app_dir: futo-backups-survey.immich.app
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/${{ matrix.app_name }}.immich.app
|
||||
working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -58,7 +61,7 @@ jobs:
|
||||
with:
|
||||
name: build-frontend-output-${{ matrix.app_name }}
|
||||
include-hidden-files: true
|
||||
path: apps/${{ matrix.app_name }}.immich.app/build
|
||||
path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/${{ matrix.build_path || 'build' }}
|
||||
retention-days: 1
|
||||
|
||||
build-backend:
|
||||
@@ -66,11 +69,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
app_name: ['datasets']
|
||||
app_name: ['datasets', 'futo-backups-survey']
|
||||
include:
|
||||
- app_name: futo-backups-survey
|
||||
app_dir: futo-backups-survey.immich.app
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/${{ matrix.app_name }}.immich.app/backend
|
||||
working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -98,7 +104,7 @@ jobs:
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: build-backend-output-${{ matrix.app_name }}
|
||||
path: 'apps/${{ matrix.app_name }}.immich.app/backend/dist'
|
||||
path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/dist
|
||||
retention-days: 1
|
||||
|
||||
deploy:
|
||||
@@ -108,11 +114,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||
include:
|
||||
- app_name: futo-backups-survey
|
||||
app_dir: futo-backups-survey.immich.app
|
||||
env:
|
||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
|
||||
TF_VAR_dist_dir: ${{ github.workspace }}/dist/backend
|
||||
TF_VAR_migrations_dir: ${{ github.workspace }}/apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/migrations
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.OP_TF_PROD_ENV || secrets.OP_TF_DEV_ENV }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -129,7 +139,8 @@ jobs:
|
||||
- name: Check if the app has a backend folder
|
||||
id: check-backend
|
||||
run: |
|
||||
if [ -d "apps/${{ matrix.app_name }}.immich.app/backend" ]; then
|
||||
APP_DIR="${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}"
|
||||
if [ -d "apps/${APP_DIR}/backend" ]; then
|
||||
echo "has_backend=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "has_backend=false" >> $GITHUB_ENV
|
||||
@@ -164,11 +175,15 @@ jobs:
|
||||
INCLUDE_DIRS='**/workers/${{ matrix.app_name }}'
|
||||
fi
|
||||
|
||||
QUEUE_ARGS=(--queue-include-dir='**/!(workers)/**')
|
||||
if [ -n "$INCLUDE_DIRS" ]; then
|
||||
QUEUE_ARGS+=(--queue-include-dir="$INCLUDE_DIRS")
|
||||
fi
|
||||
|
||||
op run --env-file='.env' -- terragrunt run --all apply \
|
||||
--non-interactive \
|
||||
--parallelism 1 \
|
||||
--queue-include-dir='**/!(workers)/**' \
|
||||
--queue-include-dir="$INCLUDE_DIRS"
|
||||
"${QUEUE_ARGS[@]}"
|
||||
|
||||
- name: Cloudflare Deploy Output
|
||||
id: deploy-output
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
node_modules
|
||||
|
||||
# IDEs
|
||||
.idea
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
apps/*/.svelte-kit
|
||||
apps/*/build
|
||||
apps/*/.wrangler
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -19,3 +23,7 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Planning
|
||||
.planning/
|
||||
apps/*/.planning
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist
|
||||
.wrangler/
|
||||
@@ -0,0 +1,21 @@
|
||||
-- migrations/0001_initial.sql
|
||||
-- Initial schema: respondents and answers tables for survey persistence
|
||||
|
||||
CREATE TABLE respondents (
|
||||
id TEXT PRIMARY KEY,
|
||||
ip_address TEXT NOT NULL,
|
||||
is_complete INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_respondents_ip ON respondents(ip_address);
|
||||
|
||||
CREATE TABLE answers (
|
||||
respondent_id TEXT NOT NULL,
|
||||
question_id TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
answered_at TEXT NOT NULL,
|
||||
PRIMARY KEY (respondent_id, question_id),
|
||||
FOREIGN KEY (respondent_id) REFERENCES respondents(id)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- migrations/0002_cookie_dedup.sql
|
||||
-- Switch from IP-based to cookie-based deduplication.
|
||||
-- IP column stays for analytics but is no longer a uniqueness constraint.
|
||||
-- Add other_text column for "Other" free-text responses on Q7, Q13-Q16.
|
||||
|
||||
DROP INDEX IF EXISTS idx_respondents_ip;
|
||||
|
||||
ALTER TABLE answers ADD COLUMN other_text TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- migrations/0003_turnstile_verified.sql
|
||||
-- Track whether the respondent passed a Cloudflare Turnstile challenge.
|
||||
-- Existing respondents default to unverified (0).
|
||||
|
||||
ALTER TABLE respondents ADD COLUMN is_verified INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "futo-backups-survey-backend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"itty-router": "^5.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const COOKIE_NAME = 'respondent_id';
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
|
||||
|
||||
export function getRespondentId(request: Request): string | undefined {
|
||||
const header = request.headers.get('Cookie') ?? '';
|
||||
const match = header.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function setRespondentCookie(headers: Headers, respondentId: string): void {
|
||||
headers.set(
|
||||
'Set-Cookie',
|
||||
`${COOKIE_NAME}=${respondentId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteRespondentCookie(headers: Headers): void {
|
||||
headers.set(
|
||||
'Set-Cookie',
|
||||
`${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { AutoRouter, cors, IRequest } from 'itty-router';
|
||||
import { getRespondentId, setRespondentCookie, deleteRespondentCookie } from './cookie';
|
||||
import { questions } from './survey-definition';
|
||||
|
||||
const { preflight, corsify } = cors();
|
||||
|
||||
const router = AutoRouter<IRequest, [Env, ExecutionContext]>({
|
||||
before: [preflight],
|
||||
finally: [corsify],
|
||||
});
|
||||
|
||||
router.post('/api/verify', async (request, env) => {
|
||||
const db = env.DB;
|
||||
const { turnstileToken } = (await request.json()) as { turnstileToken: string };
|
||||
|
||||
if (!turnstileToken) {
|
||||
return new Response('Missing turnstile token', { status: 400 });
|
||||
}
|
||||
|
||||
// dev environment — skip Cloudflare call
|
||||
if (env.CF_TURNSTILE_SECRET !== 'DEV_TURNSTILE_TOKEN') {
|
||||
const ip = request.headers.get('CF-Connecting-IP');
|
||||
if (!ip) {
|
||||
return new Response('Missing client IP', { status: 400 });
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('secret', env.CF_TURNSTILE_SECRET);
|
||||
formData.append('response', turnstileToken);
|
||||
formData.append('remoteip', ip);
|
||||
|
||||
const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const outcome = (await result.json()) as { success: boolean };
|
||||
if (!outcome.success) {
|
||||
return new Response('Turnstile validation failed', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const respondentId = getRespondentId(request);
|
||||
if (respondentId) {
|
||||
await db
|
||||
.prepare('UPDATE respondents SET is_verified = 1 WHERE id = ?')
|
||||
.bind(respondentId)
|
||||
.run();
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/api/answers', async (request, env) => {
|
||||
const db = env.DB;
|
||||
const { questionId, value, otherText } = (await request.json()) as {
|
||||
questionId: string;
|
||||
value: string;
|
||||
otherText?: string;
|
||||
};
|
||||
|
||||
const ip =
|
||||
request.headers.get('CF-Connecting-IP') ??
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
'unknown';
|
||||
|
||||
let respondentId = getRespondentId(request);
|
||||
const headers = new Headers();
|
||||
|
||||
if (!respondentId) {
|
||||
respondentId = crypto.randomUUID();
|
||||
setRespondentCookie(headers, respondentId);
|
||||
}
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO respondents (id, ip_address, created_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET ip_address = excluded.ip_address`,
|
||||
)
|
||||
.bind(respondentId, ip, new Date().toISOString())
|
||||
.run();
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (respondent_id, question_id)
|
||||
DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at`,
|
||||
)
|
||||
.bind(respondentId, questionId, value, otherText ?? null, new Date().toISOString())
|
||||
.run();
|
||||
|
||||
return new Response(null, { status: 204, headers });
|
||||
});
|
||||
|
||||
router.get('/api/resume', async (request, env) => {
|
||||
const db = env.DB;
|
||||
let respondentId = getRespondentId(request);
|
||||
const headers = new Headers();
|
||||
|
||||
if (!respondentId) {
|
||||
respondentId = crypto.randomUUID();
|
||||
const ip =
|
||||
request.headers.get('CF-Connecting-IP') ??
|
||||
request.headers.get('x-forwarded-for') ??
|
||||
'unknown';
|
||||
await db
|
||||
.prepare('INSERT INTO respondents (id, ip_address, created_at) VALUES (?, ?, ?)')
|
||||
.bind(respondentId, ip, new Date().toISOString())
|
||||
.run();
|
||||
setRespondentCookie(headers, respondentId);
|
||||
return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers });
|
||||
}
|
||||
|
||||
const respondent = await db
|
||||
.prepare('SELECT id, is_complete, is_verified FROM respondents WHERE id = ?')
|
||||
.bind(respondentId)
|
||||
.first<{ id: string; is_complete: number; is_verified: number }>();
|
||||
|
||||
if (!respondent) {
|
||||
return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers });
|
||||
}
|
||||
|
||||
if (respondent.is_complete) {
|
||||
return Response.json({ isComplete: true }, { headers });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.prepare('SELECT question_id, answer, other_text FROM answers WHERE respondent_id = ?')
|
||||
.bind(respondentId)
|
||||
.all<{ question_id: string; answer: string; other_text: string | null }>();
|
||||
|
||||
const answers: Record<string, { value: string; otherText?: string }> = {};
|
||||
for (const row of rows.results) {
|
||||
answers[row.question_id] = {
|
||||
value: row.answer,
|
||||
...(row.other_text ? { otherText: row.other_text } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let nextQuestionIndex = questions.length;
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
|
||||
if (q.conditional?.showIf.condition === 'skipped') {
|
||||
const depId = q.conditional.showIf.questionId;
|
||||
if (answers[depId]) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!answers[q.id]) {
|
||||
nextQuestionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ answers, nextQuestionIndex, isVerified: !!respondent.is_verified }, { headers });
|
||||
});
|
||||
|
||||
router.post('/api/complete', async (request, env) => {
|
||||
const db = env.DB;
|
||||
const respondentId = getRespondentId(request);
|
||||
|
||||
if (!respondentId) {
|
||||
return new Response('No respondent cookie', { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.prepare('UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ?')
|
||||
.bind(new Date().toISOString(), respondentId)
|
||||
.run();
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
router.post('/api/reset', async (request) => {
|
||||
const headers = new Headers();
|
||||
deleteRespondentCookie(headers);
|
||||
return new Response(null, { status: 204, headers });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,29 @@
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
conditional?: {
|
||||
showIf: { questionId: string; condition: 'skipped' };
|
||||
};
|
||||
}
|
||||
|
||||
const opt = (label: string) => ({ label, value: label });
|
||||
|
||||
export const questions: SurveyQuestion[] = [
|
||||
{ id: 'q1' },
|
||||
{ id: 'q2' },
|
||||
{ id: 'q3' },
|
||||
{ id: 'q4' },
|
||||
{ id: 'q5' },
|
||||
{ id: 'q6' },
|
||||
{ id: 'q7' },
|
||||
{ id: 'q8' },
|
||||
{ id: 'q9' },
|
||||
{ id: 'q10' },
|
||||
{ id: 'q11' },
|
||||
{ id: 'q12' },
|
||||
{ id: 'q17' },
|
||||
{ id: 'q13' },
|
||||
{ id: 'q14' },
|
||||
{ id: 'q15' },
|
||||
{ id: 'q16' },
|
||||
{ id: 'q19' },
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"module": "es2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["./worker-configuration.d.ts"]
|
||||
},
|
||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
CF_TURNSTILE_SECRET: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "futo-backups-survey-api",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-06-03",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"CF_TURNSTILE_SECRET": "DEV_TURNSTILE_TOKEN"
|
||||
},
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "futo-backups-survey",
|
||||
"database_id": "local"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser,
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "futo-backups.surveys.immich.app",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "svelte-kit sync && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"sync": "svelte-kit sync",
|
||||
"test": "vitest",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"db:migrate:local": "cd backend && wrangler d1 migrations apply futo-backups-survey --local"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^24.12.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^4.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@immich/ui": "^0.65.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"svelte-turnstile": "^0.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FUTO Backups Survey</title>
|
||||
<meta name="theme-color" content="currentColor" />
|
||||
<meta name="description" content="Help us build FUTO Backups for Immich - share your feedback" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="bg-light text-dark dark">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Module will be imported after it exists
|
||||
let saveAnswer: typeof import('./api-client').saveAnswer;
|
||||
let fireAndForgetSave: typeof import('./api-client').fireAndForgetSave;
|
||||
let flushPendingQueue: typeof import('./api-client').flushPendingQueue;
|
||||
let getPendingQueue: typeof import('./api-client').getPendingQueue;
|
||||
let fetchResume: typeof import('./api-client').fetchResume;
|
||||
let postComplete: typeof import('./api-client').postComplete;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
// Re-import module fresh each test to reset pendingQueue
|
||||
vi.resetModules();
|
||||
const mod = await import('./api-client');
|
||||
saveAnswer = mod.saveAnswer;
|
||||
fireAndForgetSave = mod.fireAndForgetSave;
|
||||
flushPendingQueue = mod.flushPendingQueue;
|
||||
getPendingQueue = mod.getPendingQueue;
|
||||
fetchResume = mod.fetchResume;
|
||||
postComplete = mod.postComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('saveAnswer', () => {
|
||||
it('calls fetch with correct body and headers', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const data = { questionId: 'q1', value: 'test' };
|
||||
const promise = saveAnswer(data);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/answers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true on 204 response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
|
||||
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('retries on 500 response - fetch called 4 times total', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||
});
|
||||
|
||||
it('does NOT retry on 400 response - fetch called once', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries on network error (fetch throws)', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||
});
|
||||
|
||||
it('uses delays of 1000, 2000, 4000 ms between retries', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||
|
||||
// Initial call happens immediately
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After 1000ms: first retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// After 2000ms more: second retry
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
// After 4000ms more: third retry
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('fireAndForgetSave', () => {
|
||||
it('adds to pendingQueue on total failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||
|
||||
fireAndForgetSave({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
// Allow microtasks to settle
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(getPendingQueue()).toHaveLength(1);
|
||||
expect(getPendingQueue()[0]).toEqual({ questionId: 'q1', value: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPendingQueue', () => {
|
||||
it('re-fires queued items', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// First: make a save fail to populate the queue
|
||||
fireAndForgetSave({ questionId: 'q1', value: 'test' });
|
||||
await vi.runAllTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(getPendingQueue()).toHaveLength(1);
|
||||
|
||||
// Now make fetch succeed and flush
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||
const callsBefore = mockFetch.mock.calls.length;
|
||||
flushPendingQueue();
|
||||
await vi.runAllTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Queue should be empty now (re-fired successfully)
|
||||
expect(getPendingQueue()).toHaveLength(0);
|
||||
// fetch should have been called at least once more
|
||||
expect(mockFetch.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchResume', () => {
|
||||
it('calls GET /api/resume with credentials: same-origin', async () => {
|
||||
const mockResponse = { answers: {}, nextQuestionIndex: 0 };
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchResume();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/resume', { credentials: 'same-origin' });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postComplete', () => {
|
||||
it('calls POST /api/complete with credentials: same-origin', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
|
||||
await postComplete();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/complete', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SurveyAnswer } from './types';
|
||||
|
||||
/**
|
||||
* Verifies a Cloudflare Turnstile token with the backend.
|
||||
*/
|
||||
export async function verifyTurnstile(turnstileToken: string): Promise<void> {
|
||||
const res = await fetch('/api/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turnstileToken }),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Challenge verification failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingSave {
|
||||
questionId: string;
|
||||
value: string;
|
||||
otherText?: string;
|
||||
}
|
||||
|
||||
let pendingQueue: PendingSave[] = [];
|
||||
|
||||
const BACKOFF_DELAYS = [1000, 2000, 4000];
|
||||
|
||||
/**
|
||||
* Saves an answer to the server with exponential backoff retry.
|
||||
* Retries up to 3 times on server errors (5xx) and network errors.
|
||||
* Does NOT retry on client errors (4xx).
|
||||
*/
|
||||
export async function saveAnswer(data: PendingSave): Promise<boolean> {
|
||||
for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) {
|
||||
try {
|
||||
const res = await fetch('/api/answers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) return true;
|
||||
if (res.status < 500) return false; // client error, don't retry
|
||||
} catch {
|
||||
// network error, retry
|
||||
}
|
||||
if (attempt < BACKOFF_DELAYS.length) {
|
||||
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[attempt]));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let onSaveErrorCallback: ((message: string) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Registers a callback invoked when a save fails after all retries.
|
||||
*/
|
||||
export function onSaveError(cb: (message: string) => void): void {
|
||||
onSaveErrorCallback = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: saves an answer without blocking.
|
||||
* On total failure (all retries exhausted), queues for later retry and notifies via callback.
|
||||
*/
|
||||
export function fireAndForgetSave(data: PendingSave): void {
|
||||
saveAnswer(data).then((success) => {
|
||||
if (!success) {
|
||||
pendingQueue.push(data);
|
||||
onSaveErrorCallback?.('Failed to save answer. Your response will be retried automatically.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attempts all queued saves that previously failed.
|
||||
*/
|
||||
export function flushPendingQueue(): void {
|
||||
const queue = [...pendingQueue];
|
||||
pendingQueue = [];
|
||||
for (const item of queue) {
|
||||
fireAndForgetSave(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current pending queue (for testing).
|
||||
*/
|
||||
export function getPendingQueue(): PendingSave[] {
|
||||
return pendingQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches resume data from the server.
|
||||
* Returns existing answers and next question index, or isComplete flag.
|
||||
*/
|
||||
export async function fetchResume(): Promise<{
|
||||
answers?: Record<string, { value: string; otherText?: string }>;
|
||||
nextQuestionIndex?: number;
|
||||
isComplete?: boolean;
|
||||
isVerified?: boolean;
|
||||
}> {
|
||||
const res = await fetch('/api/resume', { credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load survey (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the survey as complete on the server.
|
||||
*/
|
||||
export async function postComplete(): Promise<void> {
|
||||
const res = await fetch('/api/complete', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to submit survey (${res.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@immich/ui/theme/default.css';
|
||||
@import '$common/theme/theme.css';
|
||||
@source '../../node_modules/@immich/ui/dist';
|
||||
@source '../../../../common';
|
||||
|
||||
@theme inline {
|
||||
--color-immich-primary: rgb(var(--immich-primary));
|
||||
--color-immich-primary-10: rgb(var(--immich-primary) / 0.1);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Button, Logo } from '@immich/ui';
|
||||
|
||||
async function startOver() {
|
||||
await fetch('/api/reset', { method: 'POST', credentials: 'same-origin' });
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<Logo variant="stacked" size="large" />
|
||||
|
||||
<h1 class="text-3xl font-bold">You've already completed this survey</h1>
|
||||
<p class="text-gray-500">Your responses were recorded. Thank you for your feedback!</p>
|
||||
|
||||
<Button variant="outline" onclick={startOver}>Start over</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '@immich/ui';
|
||||
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string) => void;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
let emailValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
emailValue = answer?.value ?? '';
|
||||
});
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const isValid = $derived(emailValue.length > 0 && emailPattern.test(emailValue));
|
||||
|
||||
function handleInput() {
|
||||
if (isValid) {
|
||||
onAnswer(emailValue);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||
|
||||
{#if question.description}
|
||||
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={question.placeholder ?? 'name@example.com'}
|
||||
bind:value={emailValue}
|
||||
size="medium"
|
||||
oninput={handleInput}
|
||||
/>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Input } from '@immich/ui';
|
||||
import { mdiCheck } from '@mdi/js';
|
||||
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string, otherText?: string) => void;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
|
||||
let emailValue = $state('');
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let initialized = false;
|
||||
|
||||
$effect(() => {
|
||||
emailValue = answer?.value ?? '';
|
||||
if (answer?.otherText) {
|
||||
selected = new Set(answer.otherText.split(','));
|
||||
} else if (!initialized) {
|
||||
// Pre-check all options by default
|
||||
selected = new Set((question.options ?? []).map((o) => o.value));
|
||||
}
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const isValid = $derived(emailValue.length > 0 && emailPattern.test(emailValue));
|
||||
|
||||
function emitAnswer() {
|
||||
if (isValid && selected.size > 0) {
|
||||
onAnswer(emailValue, [...selected].join(','));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOption(value: string) {
|
||||
if (selected.has(value)) {
|
||||
selected.delete(value);
|
||||
} else {
|
||||
selected.add(value);
|
||||
}
|
||||
selected = new Set(selected);
|
||||
emitAnswer();
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
emitAnswer();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||
|
||||
{#if question.description}
|
||||
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={question.placeholder ?? 'name@example.com'}
|
||||
bind:value={emailValue}
|
||||
size="medium"
|
||||
oninput={handleInput}
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
{#each question.options ?? [] as option}
|
||||
<button
|
||||
class="flex min-h-12 w-full items-center gap-3 rounded-lg border-2 p-4 text-left transition-all duration-150
|
||||
{selected.has(option.value)
|
||||
? 'border-immich-primary bg-immich-primary-10'
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
onclick={() => toggleOption(option.value)}
|
||||
>
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded {selected.has(option.value) ? 'bg-immich-primary text-white' : 'border-2 border-gray-300 dark:border-gray-500'}">
|
||||
{#if selected.has(option.value)}
|
||||
<Icon icon={mdiCheck} size="14" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-base">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-sm text-gray-500">
|
||||
If you only select the closed beta, we'll never email you except for the beta invite.
|
||||
</p>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@immich/ui';
|
||||
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||
import RadioQuestion from './RadioQuestion.svelte';
|
||||
import EmailQuestion from './EmailQuestion.svelte';
|
||||
import EmailSignupQuestion from './EmailSignupQuestion.svelte';
|
||||
import TextareaQuestion from './TextareaQuestion.svelte';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string, otherText?: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
canGoBack: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer, onNext, onBack, canGoBack, isLast = false }: Props = $props();
|
||||
|
||||
const hasAnswer = $derived(!!answer?.value);
|
||||
const isOptional = $derived(!question.required);
|
||||
|
||||
let autoNextTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleRadioAnswer(value: string, otherText?: string) {
|
||||
onAnswer(value, otherText);
|
||||
// Auto-advance on radio pick (but not for "Other" with text input)
|
||||
if (value !== 'Other' || !question.hasOther) {
|
||||
clearTimeout(autoNextTimer);
|
||||
autoNextTimer = setTimeout(() => onNext(), 200);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-[640px] px-4">
|
||||
{#if question.type === 'radio'}
|
||||
<RadioQuestion {question} {answer} onAnswer={handleRadioAnswer} />
|
||||
{:else if question.type === 'email'}
|
||||
<EmailQuestion {question} {answer} onAnswer={(v) => onAnswer(v)} />
|
||||
{:else if question.type === 'email-signup'}
|
||||
<EmailSignupQuestion {question} {answer} {onAnswer} />
|
||||
{:else if question.type === 'textarea'}
|
||||
<TextareaQuestion {question} {answer} onAnswer={(v) => onAnswer(v)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 z-40 flex w-full justify-center bg-light px-4 pb-6 pt-4">
|
||||
<div class="flex w-full max-w-[640px] items-center gap-3">
|
||||
{#if canGoBack}
|
||||
<Button variant="outline" onclick={onBack}>Back</Button>
|
||||
{/if}
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!hasAnswer && !isOptional}
|
||||
onclick={onNext}
|
||||
>
|
||||
{isLast ? 'Submit' : hasAnswer || !isOptional ? 'Next' : 'Skip'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Icon, Input } from '@immich/ui';
|
||||
import { mdiCheck } from '@mdi/js';
|
||||
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string, otherText?: string) => void;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
let otherText = $state('');
|
||||
|
||||
$effect(() => {
|
||||
otherText = answer?.otherText ?? '';
|
||||
});
|
||||
function select(value: string) {
|
||||
if (value === 'Other' && question.hasOther) {
|
||||
onAnswer(value, otherText);
|
||||
} else {
|
||||
onAnswer(value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||
|
||||
{#if question.description}
|
||||
<p class="mb-6 text-base text-gray-500">{@html question.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each (question.options ?? []).filter((o) => !question.hasOther || o.value !== 'Other') as option}
|
||||
<button
|
||||
class="flex min-h-12 w-full items-center gap-3 rounded-lg border-2 p-4 text-left transition-all duration-150
|
||||
{answer?.value === option.value
|
||||
? 'border-immich-primary bg-immich-primary-10'
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
onclick={() => select(option.value)}
|
||||
>
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === option.value ? 'bg-immich-primary text-white' : 'border-2 border-gray-300 dark:border-gray-500'}">
|
||||
{#if answer?.value === option.value}
|
||||
<Icon icon={mdiCheck} size="14" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-base">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if question.hasOther}
|
||||
<div
|
||||
class="flex min-h-12 w-full items-center gap-3 rounded-lg border-2 p-4 transition-all duration-150
|
||||
{answer?.value === 'Other'
|
||||
? 'border-immich-primary bg-immich-primary-10'
|
||||
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||
>
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === 'Other' ? 'bg-immich-primary text-white' : 'border-2 border-gray-300 dark:border-gray-500'}">
|
||||
{#if answer?.value === 'Other'}
|
||||
<Icon icon={mdiCheck} size="14" />
|
||||
{/if}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-base outline-none placeholder:text-gray-400"
|
||||
placeholder={question.otherPrompt ?? 'Other...'}
|
||||
bind:value={otherText}
|
||||
onfocus={() => onAnswer('Other', otherText)}
|
||||
oninput={() => onAnswer('Other', otherText)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { sections } from '$lib/survey-definition';
|
||||
import type { SurveySection } from '$lib/types';
|
||||
import { Button } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
section: SurveySection;
|
||||
onContinue: () => void;
|
||||
onBack?: () => void;
|
||||
onSkip?: () => void;
|
||||
canGoBack?: boolean;
|
||||
}
|
||||
|
||||
let { section, onContinue, onBack, onSkip, canGoBack = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||
<div class="flex max-w-[640px] flex-col gap-4">
|
||||
<span class="text-sm font-medium uppercase tracking-wider text-gray-400">
|
||||
Section {section.number} of {sections.length}
|
||||
</span>
|
||||
<h1 class="text-3xl font-bold">{section.title}</h1>
|
||||
{#if section.description}
|
||||
<p class="text-base text-gray-500">{section.description}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3">
|
||||
{#if canGoBack && onBack}
|
||||
<Button variant="outline" onclick={onBack}>Back</Button>
|
||||
{/if}
|
||||
<Button color="primary" onclick={onContinue}>Continue</Button>
|
||||
{#if onSkip}
|
||||
<button
|
||||
onclick={onSkip}
|
||||
class="text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Skip this section
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { questions, sections } from '$lib/survey-definition';
|
||||
import type { SurveySection } from '$lib/types';
|
||||
import type { createSurveyEngine } from '$lib/survey-engine.svelte';
|
||||
import { findNextVisibleIndex } from '$lib/survey-engine.svelte';
|
||||
import QuestionCard from './QuestionCard.svelte';
|
||||
import SectionHeader from './SectionHeader.svelte';
|
||||
|
||||
interface Props {
|
||||
engine: ReturnType<typeof createSurveyEngine>;
|
||||
onAnswer: (questionId: string, value: string, otherText?: string) => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let { engine, onAnswer, onComplete }: Props = $props();
|
||||
|
||||
let direction = $state(1);
|
||||
let transitioning = $state(false);
|
||||
let dismissedSections = $state<Set<number>>(new Set());
|
||||
|
||||
const needsSectionHeader = $derived.by(() => {
|
||||
const q = engine.currentQuestion;
|
||||
if (!q) return null;
|
||||
return sections.find((s) => s.questionIds[0] === q.id) ?? null;
|
||||
});
|
||||
|
||||
const showingSectionHeader = $derived.by(() => {
|
||||
const section = needsSectionHeader;
|
||||
return !!(section && !dismissedSections.has(section.number));
|
||||
});
|
||||
|
||||
const currentSection = $derived.by(() => {
|
||||
return showingSectionHeader ? needsSectionHeader : null;
|
||||
});
|
||||
|
||||
const isLastQuestion = $derived(
|
||||
findNextVisibleIndex(engine.currentIndex, questions, engine.answers) >= questions.length,
|
||||
);
|
||||
|
||||
const skippableSectionNumbers = new Set([5]);
|
||||
|
||||
function dismissSection() {
|
||||
if (currentSection) {
|
||||
dismissedSections.add(currentSection.number);
|
||||
dismissedSections = new Set(dismissedSections);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function skipSection() {
|
||||
if (!currentSection) return;
|
||||
const nextSection = sections.find((s) => s.number === currentSection!.number + 1);
|
||||
if (nextSection) {
|
||||
engine.goTo(nextSection.questionIds[0]);
|
||||
}
|
||||
dismissedSections.add(currentSection.number);
|
||||
dismissedSections = new Set(dismissedSections);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function handleAnswer(questionId: string, value: string, otherText?: string) {
|
||||
onAnswer(questionId, value, otherText);
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (transitioning) return;
|
||||
transitioning = true;
|
||||
direction = 1;
|
||||
engine.next();
|
||||
if (engine.isComplete) {
|
||||
onComplete();
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
setTimeout(() => {
|
||||
transitioning = false;
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (transitioning) return;
|
||||
transitioning = true;
|
||||
direction = -1;
|
||||
|
||||
if (showingSectionHeader && currentSection) {
|
||||
// On a section header — go back to previous section's last question
|
||||
dismissedSections.delete(currentSection.number);
|
||||
dismissedSections = new Set(dismissedSections);
|
||||
engine.previous();
|
||||
} else {
|
||||
const currentQ = engine.currentQuestion;
|
||||
const currentSec = currentQ ? sections.find((s) => s.questionIds.includes(currentQ.id)) : null;
|
||||
|
||||
if (currentSec && currentQ?.id === currentSec.questionIds[0]) {
|
||||
// At the first question of a section — re-show this section's header
|
||||
dismissedSections.delete(currentSec.number);
|
||||
dismissedSections = new Set(dismissedSections);
|
||||
} else {
|
||||
engine.previous();
|
||||
}
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
setTimeout(() => {
|
||||
transitioning = false;
|
||||
}, 350);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative min-h-screen">
|
||||
<div class="fixed top-0 left-0 z-50 h-1 w-full bg-gray-700">
|
||||
<div
|
||||
class="h-full bg-immich-primary transition-all duration-300"
|
||||
style="width: {engine.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-screen overflow-hidden">
|
||||
{#key `${engine.currentIndex}-${showingSectionHeader}`}
|
||||
<div
|
||||
class="w-full"
|
||||
in:fly={{ y: direction * 50, duration: 300, easing: cubicOut }}
|
||||
out:fly={{ y: direction * -50, duration: 300, easing: cubicOut }}
|
||||
>
|
||||
{#if showingSectionHeader && currentSection}
|
||||
<SectionHeader
|
||||
section={currentSection}
|
||||
onContinue={dismissSection}
|
||||
onBack={handleBack}
|
||||
onSkip={skippableSectionNumbers.has(currentSection.number) ? skipSection : undefined}
|
||||
canGoBack={currentSection.number > 1}
|
||||
/>
|
||||
{:else if engine.currentQuestion}
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-4 pb-24">
|
||||
<QuestionCard
|
||||
question={engine.currentQuestion}
|
||||
answer={engine.answers[engine.currentQuestion.id]}
|
||||
onAnswer={(value, otherText) => handleAnswer(engine.currentQuestion!.id, value, otherText)}
|
||||
onNext={handleNext}
|
||||
onBack={handleBack}
|
||||
canGoBack={engine.currentIndex > 0}
|
||||
isLast={isLastQuestion}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Textarea } from '@immich/ui';
|
||||
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string) => void;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
let textValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
textValue = answer?.value ?? '';
|
||||
});
|
||||
|
||||
const maxLength = $derived(question.maxLength ?? 5000);
|
||||
|
||||
$effect(() => {
|
||||
if (textValue.length > maxLength) {
|
||||
textValue = textValue.slice(0, maxLength);
|
||||
}
|
||||
});
|
||||
|
||||
function handleInput() {
|
||||
onAnswer(textValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||
|
||||
{#if question.description}
|
||||
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||
{/if}
|
||||
|
||||
<Textarea
|
||||
bind:value={textValue}
|
||||
rows={6}
|
||||
maxlength={maxLength}
|
||||
oninput={handleInput}
|
||||
/>
|
||||
|
||||
<p class="mt-1 text-right text-sm text-gray-400">{textValue.length}/{maxLength}</p>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Logo } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<Logo variant="icon" size="giant" />
|
||||
|
||||
<div class="checkmark-container">
|
||||
<svg class="checkmark" viewBox="0 0 52 52">
|
||||
<circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none" />
|
||||
<path class="checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold">Thank you!</h1>
|
||||
<p class="text-gray-500">Your responses have been recorded.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.checkmark-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.checkmark-circle {
|
||||
stroke: rgb(var(--immich-primary));
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
animation: stroke 600ms cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
|
||||
.checkmark-check {
|
||||
stroke: rgb(var(--immich-primary));
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: stroke 300ms cubic-bezier(0.65, 0, 0.45, 1) 400ms forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_CF_TURNSTILE_SITE } from '$env/static/public';
|
||||
import { Button, Logo } from '@immich/ui';
|
||||
import { Turnstile } from 'svelte-turnstile';
|
||||
import { verifyTurnstile } from '$lib/api-client';
|
||||
|
||||
interface Props {
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
let { onStart }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
|
||||
<Logo variant="stacked" size="giant" />
|
||||
|
||||
<div class="flex max-w-[640px] flex-col gap-4 text-base">
|
||||
<h1 class="text-3xl font-bold">FUTO Backups</h1>
|
||||
|
||||
<p class="text-gray-500">
|
||||
Hey, thanks for taking the time to fill out the FUTO Backups survey. We estimate this will
|
||||
take a maximum of 5 minutes of your time. As a thanks for completing this survey, you can
|
||||
provide your email at the end and we will invite you to the closed beta of the product when it
|
||||
launches, which will be free for the duration of the beta period.
|
||||
</p>
|
||||
|
||||
<p class="text-gray-500">This survey is broken down in 6 sections;</p>
|
||||
|
||||
<ol class="list-decimal space-y-1 pl-6 text-gray-500">
|
||||
<li>Questions about your immich server, your library and your internet speed</li>
|
||||
<li>Your current backup solution</li>
|
||||
<li>Questions about FUTO Backups - and your interest in it</li>
|
||||
<li>Private Beta Signup</li>
|
||||
<li>Additional questions about how you host Immich</li>
|
||||
<li>Final Thoughts</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
siteKey={PUBLIC_CF_TURNSTILE_SITE}
|
||||
on:callback={(e) => verifyTurnstile(e.detail.token).catch(() => {})}
|
||||
on:error={() => {}}
|
||||
/>
|
||||
|
||||
<Button color="primary" onclick={onStart}>Get Started</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test, describe } from 'vitest';
|
||||
import { questions, sections } from './survey-definition';
|
||||
|
||||
describe('survey-definition', () => {
|
||||
test('has exactly 18 questions', () => {
|
||||
expect(questions).toHaveLength(18);
|
||||
});
|
||||
|
||||
test('questions have expected ids in order', () => {
|
||||
const ids = questions.map((q) => q.id);
|
||||
const expected = [
|
||||
...Array.from({ length: 12 }, (_, i) => `q${i + 1}`),
|
||||
'q17',
|
||||
'q13', 'q14', 'q15', 'q16',
|
||||
'q19',
|
||||
];
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
test('has exactly 6 sections', () => {
|
||||
expect(sections).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('section 1 contains q1-q6', () => {
|
||||
expect(sections[0].questionIds).toEqual(['q1', 'q2', 'q3', 'q4', 'q5', 'q6']);
|
||||
});
|
||||
|
||||
test('section 2 contains q7-q9', () => {
|
||||
expect(sections[1].questionIds).toEqual(['q7', 'q8', 'q9']);
|
||||
});
|
||||
|
||||
test('section 3 contains q10-q12', () => {
|
||||
expect(sections[2].questionIds).toEqual(['q10', 'q11', 'q12']);
|
||||
});
|
||||
|
||||
test('section 4 contains q17', () => {
|
||||
expect(sections[3].questionIds).toEqual(['q17']);
|
||||
});
|
||||
|
||||
test('section 5 contains q13-q16', () => {
|
||||
expect(sections[4].questionIds).toEqual(['q13', 'q14', 'q15', 'q16']);
|
||||
});
|
||||
|
||||
test('section 6 contains q19', () => {
|
||||
expect(sections[5].questionIds).toEqual(['q19']);
|
||||
});
|
||||
|
||||
test('all radio questions have at least 2 options', () => {
|
||||
const radioQs = questions.filter((q) => q.type === 'radio');
|
||||
for (const q of radioQs) {
|
||||
expect(q.options?.length, `${q.id} should have >= 2 options`).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('q7, q13, q14, q15, q16 have hasOther=true', () => {
|
||||
const otherIds = ['q7', 'q13', 'q14', 'q15', 'q16'];
|
||||
for (const id of otherIds) {
|
||||
const q = questions.find((q) => q.id === id);
|
||||
expect(q?.hasOther, `${id} should have hasOther=true`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('q17 is email-signup type with options', () => {
|
||||
const q17 = questions.find((q) => q.id === 'q17');
|
||||
expect(q17?.type).toBe('email-signup');
|
||||
expect(q17?.options?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('q19 is textarea with 5000 char limit', () => {
|
||||
const q19 = questions.find((q) => q.id === 'q19');
|
||||
expect(q19?.type).toBe('textarea');
|
||||
expect(q19?.maxLength).toBe(5000);
|
||||
});
|
||||
|
||||
test('q5, q12, q17, q19 have descriptions', () => {
|
||||
const descIds = ['q5', 'q12', 'q17', 'q19'];
|
||||
for (const id of descIds) {
|
||||
const q = questions.find((q) => q.id === id);
|
||||
expect(q?.description, `${id} should have a description`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('no question has an empty text field', () => {
|
||||
for (const q of questions) {
|
||||
expect(q.text.length, `${q.id} should have non-empty text`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import type { SurveyQuestion, SurveySection } from './types';
|
||||
|
||||
const opt = (label: string) => ({ label, value: label });
|
||||
|
||||
export const questions: SurveyQuestion[] = [
|
||||
// Section 1: Questions about your Immich server, your library and your internet speed
|
||||
{
|
||||
id: 'q1',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: 'Where is your Immich server located?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter where your Immich server is located',
|
||||
options: [
|
||||
opt('North America - East (US East Coast, Eastern Canada)'),
|
||||
opt('North America - Central (US Midwest, Central Canada)'),
|
||||
opt('North America - West (US West Coast, Western Canada)'),
|
||||
opt('South America'),
|
||||
opt('Europe - West (UK, France, Netherlands, Nordics, etc.)'),
|
||||
opt('Europe - East (Poland, Romania, Balkans, Baltics, etc.)'),
|
||||
opt('Asia-Pacific - East (Japan, South Korea, Taiwan, etc.)'),
|
||||
opt('Asia-Pacific - Southeast (Singapore, Australia, India, etc.)'),
|
||||
opt('Middle East / Africa'),
|
||||
opt('Other'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: "What is your server's download speed?",
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Less than 10Mbps'),
|
||||
opt('10 - 25Mbps'),
|
||||
opt('26 - 50Mbps'),
|
||||
opt('51 - 100Mbps'),
|
||||
opt('101 - 500Mbps'),
|
||||
opt('501 - 1Gbps'),
|
||||
opt('Over 1Gbps'),
|
||||
opt("I'm not sure"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q3',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: "What is your server's upload speed?",
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Less than 10Mbps'),
|
||||
opt('10 - 25Mbps'),
|
||||
opt('26 - 50Mbps'),
|
||||
opt('51 - 100Mbps'),
|
||||
opt('101 - 500Mbps'),
|
||||
opt('501 - 1Gbps'),
|
||||
opt('Over 1Gbps'),
|
||||
opt("I'm not sure"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: 'What is the total size of your Immich library?',
|
||||
description: 'You can find this on your <a href="https://my.immich.app/admin/server-status" target="_blank" rel="noopener noreferrer" class="underline text-immich-primary">server status page</a>.',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Less than 100GB'),
|
||||
opt('100GB - 250GB'),
|
||||
opt('250GB - 500GB'),
|
||||
opt('500GB - 1TB'),
|
||||
opt('1 - 5TB'),
|
||||
opt('5 - 10TB'),
|
||||
opt('10 - 50TB'),
|
||||
opt('50TB+'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q5',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: 'Roughly what percentage of your library by storage used is video?',
|
||||
description:
|
||||
'Example: If you have 600GB of photos & videos combined and 500GB of that is videos, select 80%.',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Almost none (less than 10%)'),
|
||||
opt('A small portion (10 - 25%)'),
|
||||
opt('A significant chunk (25 - 50%)'),
|
||||
opt('Mostly video (50 - 75%)'),
|
||||
opt('Almost entirely video (75%+)'),
|
||||
opt("I'm not sure"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q6',
|
||||
section: 1,
|
||||
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||
text: 'How would you describe your self-hosting experience?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Novice - Immich is the first and only service I run and I followed a guide to do that'),
|
||||
opt('Beginner - I have a few self-hosted services running but that is it'),
|
||||
opt("Intermediate - I'm comfortable with Docker, basic networking, and Linux"),
|
||||
opt('Advanced - I manage multiple services, custom configs, and infrastructure'),
|
||||
opt('Expert - I work in IT/infrastructure professionally or have deep sysadmin experience'),
|
||||
],
|
||||
},
|
||||
|
||||
// Section 2: Your current backup solution
|
||||
{
|
||||
id: 'q7',
|
||||
section: 2,
|
||||
sectionTitle: 'Three questions about your current backup methodology',
|
||||
text: 'How do you currently back up your Immich data?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter how you currently back up your Immich instance',
|
||||
options: [
|
||||
opt("I don't currently have a backup"),
|
||||
opt('RAID (but no offsite/separate copy)'),
|
||||
opt('ZFS snapshots / replication'),
|
||||
opt('Restic / Borg / Kopia or similar de-duplicating backup tool'),
|
||||
opt('Syncthing / rsync to another machine'),
|
||||
opt('Cloud sync (e.g. rclone to R2, S3, Backblaze B2, etc.)'),
|
||||
opt('Manual / ad-hoc copies'),
|
||||
opt('Other'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q8',
|
||||
section: 2,
|
||||
sectionTitle: 'Three questions about your current backup methodology',
|
||||
text: 'How often do you run backups?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Continuously / real-time'),
|
||||
opt('Daily'),
|
||||
opt('Weekly'),
|
||||
opt('Monthly'),
|
||||
opt('Less than monthly'),
|
||||
opt("I don't have automated backups"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q9',
|
||||
section: 2,
|
||||
sectionTitle: 'Three questions about your current backup methodology',
|
||||
text: 'Roughly how much new data do you add to your Immich library in a typical month?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Less than 1GB'),
|
||||
opt('1 - 10GB'),
|
||||
opt('10 - 50GB'),
|
||||
opt('50 - 100GB'),
|
||||
opt('100 - 500GB'),
|
||||
opt('Over 500GB'),
|
||||
],
|
||||
},
|
||||
|
||||
// Section 3: Questions about FUTO Backups - and your interest in it
|
||||
{
|
||||
id: 'q10',
|
||||
section: 3,
|
||||
sectionTitle: 'The Backup Service',
|
||||
text: 'How interested are you in a service like this?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('Very interested'),
|
||||
opt('Interested'),
|
||||
opt('Undecided / Need more details'),
|
||||
opt('Not interested'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q11',
|
||||
section: 3,
|
||||
sectionTitle: 'The Backup Service',
|
||||
text: 'How much would you be willing to pay per TB per month for this service?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('$3.99/TB/Month'),
|
||||
opt('$4.99/TB/Month'),
|
||||
opt('$5.99/TB/Month'),
|
||||
opt('$6.99/TB/Month'),
|
||||
opt('$7.99/TB/Month'),
|
||||
opt('$8.99/TB/Month'),
|
||||
opt('$9.99/TB/Month'),
|
||||
opt("I wouldn't pay for this"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q12',
|
||||
section: 3,
|
||||
sectionTitle: 'The Backup Service',
|
||||
text: 'How many copies of your backup would you want stored in the cloud?',
|
||||
description:
|
||||
'Each additional copy is stored in a separate location for extra redundancy and will cost extra',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
opt('1 copy'),
|
||||
opt('2 copies'),
|
||||
opt('3 copies'),
|
||||
opt('4+ copies'),
|
||||
opt("I'm not sure / It depends on the cost"),
|
||||
],
|
||||
},
|
||||
|
||||
// Section 4: Private Beta Signup
|
||||
{
|
||||
id: 'q17',
|
||||
section: 4,
|
||||
sectionTitle: 'Beta Program',
|
||||
text: 'Stay in the loop',
|
||||
description:
|
||||
"As we've explained, we're working on FUTO Backups so that you can securely backup your Immich library. The closed beta will be completely free. You can sign up here if you'd like, but this is totally optional.",
|
||||
type: 'email-signup',
|
||||
required: false,
|
||||
placeholder: 'name@example.com',
|
||||
options: [
|
||||
opt('Join the closed beta'),
|
||||
opt('Receive product updates'),
|
||||
],
|
||||
},
|
||||
|
||||
// Section 5: Additional questions about how you host Immich
|
||||
{
|
||||
id: 'q13',
|
||||
section: 5,
|
||||
sectionTitle: 'Additional questions about your Immich instance',
|
||||
text: 'What type of storage does your Immich server use for its media library?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter what type of storage does your Immich server use for its media library',
|
||||
options: [
|
||||
opt('Directly attached storage'),
|
||||
opt('Network attached storage'),
|
||||
opt('Cloud/object storage (e.g. S3/MinIO)'),
|
||||
opt('Other'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q14',
|
||||
section: 5,
|
||||
sectionTitle: 'Additional questions about your Immich instance',
|
||||
text: 'What type of drives primarily make up your media storage?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter what type of drives primarily make up your media storage',
|
||||
options: [opt('NVMe SSD'), opt('SATA SSD'), opt('HDD (spinning disk)'), opt('Other')],
|
||||
},
|
||||
{
|
||||
id: 'q15',
|
||||
section: 5,
|
||||
sectionTitle: 'Additional questions about your Immich instance',
|
||||
text: 'What is the underlying OS or Hypervisor running your Immich server?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter the underlying OS or Hypervisor running your Immich server',
|
||||
options: [
|
||||
opt('Bare metal Linux (Ubuntu, Debian, etc.)'),
|
||||
opt('Proxmox VE'),
|
||||
opt('TrueNAS / FreeNAS'),
|
||||
opt('Unraid'),
|
||||
opt('Windows / MacOS'),
|
||||
opt('NixOS'),
|
||||
opt('Umbrel'),
|
||||
opt('ZimaOS'),
|
||||
opt('HexOS'),
|
||||
opt('Other'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'q16',
|
||||
section: 5,
|
||||
sectionTitle: 'Additional questions about your Immich instance',
|
||||
text: 'How are you running Immich?',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
hasOther: true,
|
||||
otherPrompt: 'Please enter how you are running Immich',
|
||||
options: [
|
||||
opt('Docker / Docker Compose'),
|
||||
opt('Kubernetes'),
|
||||
opt('LXC / System Container'),
|
||||
opt('Nix'),
|
||||
opt('Unraid App'),
|
||||
opt('TrueNAS App / Chart'),
|
||||
opt('Other'),
|
||||
],
|
||||
},
|
||||
|
||||
// Section 6: Final thoughts
|
||||
{
|
||||
id: 'q19',
|
||||
section: 6,
|
||||
sectionTitle: 'Final Thoughts',
|
||||
text: 'Is there anything else you\'d like to share?',
|
||||
description:
|
||||
"Any thoughts, concerns, features you'd want, or questions about a managed backup service for Immich",
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
maxLength: 5000,
|
||||
},
|
||||
];
|
||||
|
||||
export const sections: SurveySection[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Questions about your Immich server, your library and your internet speed',
|
||||
questionIds: ['q1', 'q2', 'q3', 'q4', 'q5', 'q6'],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Three questions about your current backup methodology',
|
||||
description:
|
||||
"We'd like to know how many of your precious memories are at risk of going 💨",
|
||||
questionIds: ['q7', 'q8', 'q9'],
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'The Backup Service',
|
||||
description:
|
||||
"We're building a managed backup service that integrates directly into Immich. Your library would be backed up to a remote datacentre with end-to-end encryption, meaning only you hold the keys to your data. The idea is for this service to be extremely simple to setup and should include all kinds of things that usually you need to setup yourself. e.g. Alerting on failed backups, automated restore tests, integrity checks and more.",
|
||||
questionIds: ['q10', 'q11', 'q12'],
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: 'Beta Program',
|
||||
description:
|
||||
"We're planning a closed beta before the full launch. If you'd like early access or product updates, leave your email on the next screen.",
|
||||
questionIds: ['q17'],
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
title: 'Additional questions about your Immich instance',
|
||||
description:
|
||||
"We have further questions when it comes to your Immich instance. Your answers will allow us to better tailor Immich's development in the future.",
|
||||
questionIds: ['q13', 'q14', 'q15', 'q16'],
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
title: 'Final Thoughts',
|
||||
questionIds: ['q19'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,133 @@
|
||||
import { questions } from './survey-definition';
|
||||
import type { SurveyAnswer, SurveyQuestion } from './types';
|
||||
|
||||
/**
|
||||
* Pure function: determines if a question should be visible given current answers.
|
||||
* Q18 shows only when Q17 is skipped (has no answer).
|
||||
*/
|
||||
export function shouldShowQuestion(
|
||||
q: SurveyQuestion,
|
||||
answers: Record<string, SurveyAnswer>,
|
||||
): boolean {
|
||||
if (!q.conditional) return true;
|
||||
if (q.conditional.showIf.condition === 'skipped') {
|
||||
return !(q.conditional.showIf.questionId in answers);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function: finds the next visible question index after currentIndex.
|
||||
* Returns questions.length if no more visible questions (signals completion).
|
||||
*/
|
||||
export function findNextVisibleIndex(
|
||||
currentIndex: number,
|
||||
qs: SurveyQuestion[],
|
||||
answers: Record<string, SurveyAnswer>,
|
||||
): number {
|
||||
let next = currentIndex + 1;
|
||||
while (next < qs.length && !shouldShowQuestion(qs[next], answers)) {
|
||||
next++;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function: finds the previous visible question index before currentIndex.
|
||||
* Returns 0 if already at the beginning.
|
||||
*/
|
||||
export function findPrevVisibleIndex(
|
||||
currentIndex: number,
|
||||
qs: SurveyQuestion[],
|
||||
answers: Record<string, SurveyAnswer>,
|
||||
): number {
|
||||
let prev = currentIndex - 1;
|
||||
while (prev > 0 && !shouldShowQuestion(qs[prev], answers)) {
|
||||
prev--;
|
||||
}
|
||||
return Math.max(prev, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function: counts visible questions given current answers.
|
||||
*/
|
||||
export function getVisibleQuestionCount(
|
||||
qs: SurveyQuestion[],
|
||||
answers: Record<string, SurveyAnswer>,
|
||||
): number {
|
||||
return qs.filter((q) => shouldShowQuestion(q, answers)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive survey engine using Svelte 5 runes.
|
||||
* Manages navigation, answers, conditional logic, and completion state.
|
||||
*/
|
||||
export function createSurveyEngine(
|
||||
initialAnswers: Record<string, SurveyAnswer> = {},
|
||||
startIndex = 0,
|
||||
) {
|
||||
let currentIndex = $state(startIndex);
|
||||
let answers = $state<Record<string, SurveyAnswer>>({ ...initialAnswers });
|
||||
let isComplete = $state(false);
|
||||
|
||||
const currentQuestion = $derived(questions[currentIndex] as SurveyQuestion | undefined);
|
||||
const totalVisible = $derived(getVisibleQuestionCount(questions, answers));
|
||||
const progress = $derived((Object.keys(answers).length / totalVisible) * 100);
|
||||
|
||||
function next() {
|
||||
const nextIdx = findNextVisibleIndex(currentIndex, questions, answers);
|
||||
if (nextIdx >= questions.length) {
|
||||
isComplete = true;
|
||||
} else {
|
||||
currentIndex = nextIdx;
|
||||
}
|
||||
}
|
||||
|
||||
function previous() {
|
||||
currentIndex = findPrevVisibleIndex(currentIndex, questions, answers);
|
||||
}
|
||||
|
||||
function goTo(questionId: string) {
|
||||
const idx = questions.findIndex((q) => q.id === questionId);
|
||||
if (idx >= 0) {
|
||||
currentIndex = idx;
|
||||
}
|
||||
}
|
||||
|
||||
function setAnswer(questionId: string, value: string, otherText?: string) {
|
||||
answers[questionId] = { value, ...(otherText ? { otherText } : {}) };
|
||||
}
|
||||
|
||||
function initialize(resumedAnswers: Record<string, SurveyAnswer>, resumeIndex: number) {
|
||||
answers = { ...resumedAnswers };
|
||||
currentIndex = resumeIndex;
|
||||
isComplete = false;
|
||||
}
|
||||
|
||||
return {
|
||||
get currentIndex() {
|
||||
return currentIndex;
|
||||
},
|
||||
get currentQuestion() {
|
||||
return currentQuestion;
|
||||
},
|
||||
get answers() {
|
||||
return answers;
|
||||
},
|
||||
get progress() {
|
||||
return progress;
|
||||
},
|
||||
get totalVisible() {
|
||||
return totalVisible;
|
||||
},
|
||||
get isComplete() {
|
||||
return isComplete;
|
||||
},
|
||||
next,
|
||||
previous,
|
||||
goTo,
|
||||
setAnswer,
|
||||
initialize,
|
||||
shouldShowQuestion: (q: SurveyQuestion) => shouldShowQuestion(q, answers),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { questions } from './survey-definition';
|
||||
import type { SurveyAnswer, SurveyQuestion } from './types';
|
||||
import {
|
||||
shouldShowQuestion,
|
||||
findNextVisibleIndex,
|
||||
findPrevVisibleIndex,
|
||||
getVisibleQuestionCount,
|
||||
} from './survey-engine.svelte';
|
||||
|
||||
// Helper: find index by question id
|
||||
function indexOf(id: string): number {
|
||||
return questions.findIndex((q) => q.id === id);
|
||||
}
|
||||
|
||||
describe('shouldShowQuestion', () => {
|
||||
it('returns true for regular questions without conditional', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
expect(shouldShowQuestion(questions[0], answers)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextVisibleIndex', () => {
|
||||
it('from Q12 goes to Q17 normally', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
const q12Index = indexOf('q12');
|
||||
const result = findNextVisibleIndex(q12Index, questions, answers);
|
||||
expect(result).toBe(indexOf('q17'));
|
||||
});
|
||||
|
||||
it('from Q17 goes to Q13', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
const q17Index = indexOf('q17');
|
||||
const result = findNextVisibleIndex(q17Index, questions, answers);
|
||||
expect(result).toBe(indexOf('q13'));
|
||||
});
|
||||
|
||||
it('past last question returns questions.length (signals completion)', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
const lastIndex = questions.length - 1;
|
||||
const result = findNextVisibleIndex(lastIndex, questions, answers);
|
||||
expect(result).toBe(questions.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPrevVisibleIndex', () => {
|
||||
it('from Q19 goes back to Q16', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
const q19Index = indexOf('q19');
|
||||
const result = findPrevVisibleIndex(q19Index, questions, answers);
|
||||
expect(result).toBe(indexOf('q16'));
|
||||
});
|
||||
|
||||
it('at index 0 returns 0', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
const result = findPrevVisibleIndex(0, questions, answers);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleQuestionCount', () => {
|
||||
it('includes all questions when no conditional is triggered', () => {
|
||||
const answers: Record<string, SurveyAnswer> = {};
|
||||
expect(getVisibleQuestionCount(questions, answers)).toBe(questions.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export type QuestionType = 'radio' | 'email' | 'email-signup' | 'textarea';
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SurveyQuestion {
|
||||
id: string;
|
||||
section: number;
|
||||
sectionTitle: string;
|
||||
text: string;
|
||||
description?: string;
|
||||
type: QuestionType;
|
||||
options?: QuestionOption[];
|
||||
hasOther?: boolean;
|
||||
otherPrompt?: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
conditional?: {
|
||||
showIf: { questionId: string; condition: 'skipped' };
|
||||
};
|
||||
}
|
||||
|
||||
export interface SurveySection {
|
||||
number: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
questionIds: string[];
|
||||
}
|
||||
|
||||
export interface SurveyAnswer {
|
||||
value: string;
|
||||
otherText?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import '$lib/app.css';
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_CF_TURNSTILE_SITE } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
import { Turnstile } from 'svelte-turnstile';
|
||||
import { createSurveyEngine } from '$lib/survey-engine.svelte';
|
||||
import { fetchResume, fireAndForgetSave, postComplete, onSaveError, verifyTurnstile } from '$lib/api-client';
|
||||
import SurveyShell from '$lib/components/SurveyShell.svelte';
|
||||
import ThankYouScreen from '$lib/components/ThankYouScreen.svelte';
|
||||
import AlreadyCompleted from '$lib/components/AlreadyCompleted.svelte';
|
||||
import WelcomeScreen from '$lib/components/WelcomeScreen.svelte';
|
||||
|
||||
const engine = createSurveyEngine();
|
||||
let loading = $state(true);
|
||||
let alreadyCompleted = $state(false);
|
||||
let surveyFinished = $state(false);
|
||||
let showWelcome = $state(false);
|
||||
let needsVerification = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onSaveError((message) => {
|
||||
error = message;
|
||||
});
|
||||
|
||||
function onTurnstileCallback(token: string) {
|
||||
needsVerification = false;
|
||||
verifyTurnstile(token).catch(() => {});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const resume = await fetchResume();
|
||||
if (resume.isComplete) {
|
||||
alreadyCompleted = true;
|
||||
} else if (resume.answers && resume.nextQuestionIndex !== undefined && resume.nextQuestionIndex > 0) {
|
||||
needsVerification = !resume.isVerified;
|
||||
engine.initialize(resume.answers, resume.nextQuestionIndex);
|
||||
} else {
|
||||
showWelcome = true;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Something went wrong. Please try again later.';
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function handleAnswer(questionId: string, value: string, otherText?: string) {
|
||||
engine.setAnswer(questionId, value, otherText);
|
||||
fireAndForgetSave({ questionId, value, otherText });
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
try {
|
||||
await postComplete();
|
||||
surveyFinished = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to submit survey. Please try again.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error && !showWelcome && !alreadyCompleted && !surveyFinished}
|
||||
<div class="fixed top-2 left-1/2 z-[100] w-full max-w-lg -translate-x-1/2 px-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg bg-red-600 px-4 py-3 text-sm text-white shadow-lg">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)} class="shrink-0 font-semibold hover:underline">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<p class="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
{:else if error && !showWelcome && !engine.currentQuestion}
|
||||
<div class="flex min-h-screen items-center justify-center px-4">
|
||||
<div class="text-center">
|
||||
<p class="text-lg text-red-400">{error}</p>
|
||||
<button onclick={() => location.reload()} class="mt-4 text-sm text-gray-400 hover:underline">Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if alreadyCompleted}
|
||||
<AlreadyCompleted />
|
||||
{:else if surveyFinished || engine.isComplete}
|
||||
<ThankYouScreen />
|
||||
{:else if showWelcome}
|
||||
<WelcomeScreen onStart={() => (showWelcome = false)} />
|
||||
{:else}
|
||||
{#if needsVerification}
|
||||
<Turnstile
|
||||
siteKey={PUBLIC_CF_TURNSTILE_SITE}
|
||||
on:callback={(e) => onTurnstileCallback(e.detail.token)}
|
||||
on:error={() => (needsVerification = false)}
|
||||
/>
|
||||
{/if}
|
||||
<SurveyShell {engine} onAnswer={handleAnswer} onComplete={handleComplete} />
|
||||
{/if}
|
||||
@@ -0,0 +1,179 @@
|
||||
# FUTO Backups Survey
|
||||
|
||||
Survey URL: https://survey.futo.org/forms/futo-backups-survey-fy4lqx
|
||||
|
||||
## Section 1: Questions about your Immich server, your library and your internet speed
|
||||
|
||||
### Q1: Where is your Immich server located?
|
||||
- North America - East (US East Coast, Eastern Canada)
|
||||
- North America - Central (US Midwest, Central Canada)
|
||||
- North America - West (US West Coast, Western Canada)
|
||||
- South America
|
||||
- Europe - West (UK, France, Netherlands, Nordics, etc.)
|
||||
- Europe - East (Poland, Romania, Balkans, Baltics, etc.)
|
||||
- Asia-Pacific - East (Japan, South Korea, Taiwan, etc.)
|
||||
- Asia-Pacific - Southeast (Singapore, Australia, India, etc.)
|
||||
- Middle East / Africa
|
||||
- Other
|
||||
|
||||
### Q2: What is your server's download speed?
|
||||
- Less than 10Mbps
|
||||
- 10 - 25Mbps
|
||||
- 26 - 50Mbps
|
||||
- 51 - 100Mbps
|
||||
- 101 - 500Mbps
|
||||
- 501 - 1Gbps
|
||||
- Over 1Gbps
|
||||
- I'm not sure
|
||||
|
||||
### Q3: What is your server's upload speed?
|
||||
- Less than 10Mbps
|
||||
- 10 - 25Mbps
|
||||
- 26 - 50Mbps
|
||||
- 51 - 100Mbps
|
||||
- 101 - 500Mbps
|
||||
- 501 - 1Gbps
|
||||
- Over 1Gbps
|
||||
- I'm not sure
|
||||
|
||||
### Q4: What is the total size of your Immich library?
|
||||
- Less than 100GB
|
||||
- 100GB - 250GB
|
||||
- 250GB - 500GB
|
||||
- 500GB - 1TB
|
||||
- 1 - 5TB
|
||||
- 5 - 10TB
|
||||
- 10 - 50TB
|
||||
- 50TB+
|
||||
|
||||
### Q5: Roughly what percentage of your library by storage used is video?
|
||||
> Example: If you have 600GB of photos & videos combined and 500GB of that is videos, select 80%.
|
||||
|
||||
- Almost none (less than 10%)
|
||||
- A small portion (10 - 25%)
|
||||
- A significant chunk (25 - 50%)
|
||||
- Mostly video (50 - 75%)
|
||||
- Almost entirely video (75%+)
|
||||
- I'm not sure
|
||||
|
||||
### Q6: How would you describe your self-hosting experience?
|
||||
- Novice - Immich is the first and only service I run and I followed a guide to do that
|
||||
- Beginner - I have a few self-hosted services running but that is it
|
||||
- Intermediate - I'm comfortable with Docker, basic networking, and Linux
|
||||
- Advanced - I manage multiple services, custom configs, and infrastructure
|
||||
- Expert - I work in IT/infrastructure professionally or have deep sysadmin experience
|
||||
|
||||
## Section 2: Your current backup solution
|
||||
|
||||
### Q7: How do you currently back up your Immich data?
|
||||
- I don't currently have a backup
|
||||
- RAID (but no offsite/separate copy)
|
||||
- ZFS snapshots / replication
|
||||
- Restic / Borg / Kopia or similar de-duplicating backup tool
|
||||
- Syncthing / rsync to another machine
|
||||
- Cloud sync (e.g. rclone to R2, S3, Backblaze B2, etc.)
|
||||
- Manual / ad-hoc copies
|
||||
- Other
|
||||
|
||||
**"Other" text entry:** Please enter how you currently back up your Immich instance
|
||||
|
||||
### Q8: How often do you run backups?
|
||||
- Continuously / real-time
|
||||
- Daily
|
||||
- Weekly
|
||||
- Monthly
|
||||
- Less than monthly
|
||||
- I don't have automated backups
|
||||
|
||||
### Q9: Roughly how much new data do you add to your Immich library in a typical month?
|
||||
- Less than 1GB
|
||||
- 1 - 10GB
|
||||
- 10 - 50GB
|
||||
- 50 - 100GB
|
||||
- 100 - 500GB
|
||||
- Over 500GB
|
||||
|
||||
## Section 3: Questions about FUTO Backups - and your interest in it
|
||||
|
||||
### Q10: How interested are you in a service like this?
|
||||
- Very interested
|
||||
- Interested
|
||||
- Undecided / Need more details
|
||||
- Not interested
|
||||
|
||||
### Q11: How much would you be willing to pay per TB per month for this service?
|
||||
- $3.99/TB/Month
|
||||
- $4.99/TB/Month
|
||||
- $5.99/TB/Month
|
||||
- $6.99/TB/Month
|
||||
- $7.99/TB/Month
|
||||
- $8.99/TB/Month
|
||||
- $9.99/TB/Month
|
||||
- I wouldn't pay for this
|
||||
|
||||
### Q12: How many copies of your backup would you want stored in the cloud?
|
||||
> Each additional copy is stored in a separate location for extra redundancy and will cost extra
|
||||
|
||||
- 1 copy
|
||||
- 2 copies
|
||||
- 3 copies
|
||||
- 4+ copies
|
||||
- I'm not sure / It depends on the cost
|
||||
|
||||
## Section 4: Additional questions about how you host Immich
|
||||
|
||||
### Q13: What type of storage does your Immich server use for its media library?
|
||||
- Directly attached storage
|
||||
- Network attached storage
|
||||
- Cloud/object storage (e.g. S3/MinIO)
|
||||
- Other
|
||||
|
||||
**"Other" text entry:** Please enter what type of storage does your Immich server use for its media library
|
||||
|
||||
### Q14: What type of drives primarily make up your media storage?
|
||||
- NVMe SSD
|
||||
- SATA SSD
|
||||
- HDD (spinning disk)
|
||||
- Other
|
||||
|
||||
**"Other" text entry:** Please enter what type of drives primarily make up your media storage
|
||||
|
||||
### Q15: What is the underlying OS or Hypervisor running your Immich server?
|
||||
- Bare metal Linux (Ubuntu, Debian, etc.)
|
||||
- Proxmox VE
|
||||
- TrueNAS / FreeNAS
|
||||
- Unraid
|
||||
- Windows / MacOS
|
||||
- NixOS
|
||||
- Umbrel
|
||||
- ZimaOS
|
||||
- HexOS
|
||||
- Other
|
||||
|
||||
**"Other" text entry:** Please enter the underlying OS or Hypervisor running your Immich server
|
||||
|
||||
### Q16: How are you running Immich?
|
||||
- Docker / Docker Compose
|
||||
- Kubernetes
|
||||
- LXC / System Container
|
||||
- Nix
|
||||
- Other
|
||||
|
||||
**"Other" text entry:** Please enter how you are running Immich
|
||||
|
||||
## Section 5: Private Beta Signup
|
||||
|
||||
### Q17: Beta Program
|
||||
> We're planning a closed beta before the full launch. If you'd like to be considered for early access, enter your email below.
|
||||
|
||||
*Text entry:* Email address (name@example.com)
|
||||
|
||||
### Q18: Get Updates
|
||||
> You didn't enroll in the beta program, if you would still like to receive product updates though, you can enter your email below.
|
||||
|
||||
*Text entry:* Email address (name@example.com)
|
||||
|
||||
### Q19: Is there anything else you'd like to share?
|
||||
> Any thoughts, concerns, features you'd want, or questions about a managed backup service for Immich
|
||||
|
||||
*Rich text entry (0/5000 characters)*
|
||||
@@ -0,0 +1,24 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import dotenv from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
dotenv.config({ path: '../../.env' });
|
||||
|
||||
process.env.PUBLIC_CF_TURNSTILE_SITE = process.env.PUBLIC_CF_TURNSTILE_SITE || '1x00000000000000000000BB';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({ fallback: 'index.html' }),
|
||||
alias: {
|
||||
$common: '../../common',
|
||||
'$common/*': '../../common/*',
|
||||
'@immich/ui': resolve('./node_modules/@immich/ui/dist'),
|
||||
'@mdi/js': resolve('./node_modules/@mdi/js'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "../../"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['../../common'],
|
||||
},
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8787',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,6 @@ data "terraform_remote_state" "cloudflare_pages_project" {
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "cloudflare_pages_project_immich_app_${var.app_name}_${var.env}"
|
||||
schema_name = "cloudflare_pages_project_immich_app_${replace(var.app_name, "-", "_")}_${var.env}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
terraform {
|
||||
backend "pg" {}
|
||||
required_version = "~> 1.7"
|
||||
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
resource "cloudflare_d1_database" "survey" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "futo-backups-survey${local.resource_suffix}"
|
||||
}
|
||||
|
||||
resource "null_resource" "d1_migrations" {
|
||||
triggers = {
|
||||
migrations_hash = sha256(join("", [for f in fileset(var.migrations_dir, "*.sql") : filesha256("${var.migrations_dir}/${f}")]))
|
||||
}
|
||||
|
||||
provisioner "local-exec" {
|
||||
command = <<-EOT
|
||||
for f in $(ls ${var.migrations_dir}/*.sql | sort); do
|
||||
echo "Applying migration: $f"
|
||||
curl -sf -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/d1/database/${cloudflare_d1_database.survey.id}/query" \
|
||||
-H "Authorization: Bearer ${data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"sql\": $(cat "$f" | jq -Rs .)}" || echo "Migration $f may already be applied"
|
||||
done
|
||||
EOT
|
||||
}
|
||||
|
||||
depends_on = [cloudflare_d1_database.survey]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
locals {
|
||||
resource_stage = var.stage != "" ? "-${var.stage}" : ""
|
||||
resource_env = "-${var.env}"
|
||||
resource_suffix = "${local.resource_env}${local.resource_stage}"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
provider "cloudflare" {
|
||||
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
data "terraform_remote_state" "api_keys_state" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "prod_cloudflare_api_keys"
|
||||
}
|
||||
}
|
||||
|
||||
data "terraform_remote_state" "cloudflare_account" {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = var.tf_state_postgres_conn_str
|
||||
schema_name = "prod_cloudflare_account"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
terraform {
|
||||
source = "."
|
||||
|
||||
extra_arguments custom_vars {
|
||||
commands = get_terraform_commands_that_need_vars()
|
||||
}
|
||||
}
|
||||
|
||||
include {
|
||||
path = find_in_parent_folders("state.hcl")
|
||||
}
|
||||
|
||||
locals {
|
||||
env = get_env("TF_VAR_env")
|
||||
stage = get_env("TF_VAR_stage")
|
||||
app_name = replace(get_env("TF_VAR_app_name"), "-", "_")
|
||||
}
|
||||
|
||||
remote_state {
|
||||
backend = "pg"
|
||||
|
||||
config = {
|
||||
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||
schema_name = "cloudflare_workers_immich_app_${local.app_name}_${local.env}${local.stage}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
variable "tf_state_postgres_conn_str" {}
|
||||
variable "stage" {}
|
||||
variable "env" {}
|
||||
variable "app_name" {}
|
||||
variable "cloudflare_account_id" {}
|
||||
variable "dist_dir" {}
|
||||
variable "migrations_dir" {}
|
||||
@@ -0,0 +1,41 @@
|
||||
resource "cloudflare_workers_script" "api" {
|
||||
account_id = var.cloudflare_account_id
|
||||
name = "futo-backups-survey-api${local.resource_suffix}"
|
||||
content = file("${var.dist_dir}/index.js")
|
||||
compatibility_date = "2025-06-03"
|
||||
module = true
|
||||
|
||||
secret_text_binding {
|
||||
name = "CF_TURNSTILE_SECRET"
|
||||
text = data.terraform_remote_state.cloudflare_account.outputs.turnstile_default_invisible_secret
|
||||
}
|
||||
|
||||
d1_database_binding {
|
||||
name = "DB"
|
||||
database_id = cloudflare_d1_database.survey.id
|
||||
}
|
||||
}
|
||||
|
||||
data "cloudflare_zone" "immich_app" {
|
||||
name = "immich.app"
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_route" "survey_api_root" {
|
||||
pattern = "${module.domain.fqdn}/api"
|
||||
script_name = cloudflare_workers_script.api.name
|
||||
zone_id = data.cloudflare_zone.immich_app.zone_id
|
||||
}
|
||||
|
||||
resource "cloudflare_workers_route" "survey_api_wildcard" {
|
||||
pattern = "${module.domain.fqdn}/api/*"
|
||||
script_name = cloudflare_workers_script.api.name
|
||||
zone_id = data.cloudflare_zone.immich_app.zone_id
|
||||
}
|
||||
|
||||
module "domain" {
|
||||
source = "git::https://github.com/immich-app/devtools.git//tf/shared/modules/domain?ref=main"
|
||||
|
||||
app_name = var.app_name
|
||||
stage = var.stage
|
||||
env = var.env
|
||||
}
|
||||
Generated
+587
-1
@@ -401,7 +401,7 @@ importers:
|
||||
dependencies:
|
||||
'@immich/ui':
|
||||
specifier: ^0.65.0
|
||||
version: 0.65.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(tailwindcss@4.2.2)
|
||||
version: 0.65.1(svelte@5.53.11)(tailwindcss@4.2.2)
|
||||
'@tsndr/cloudflare-worker-jwt':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.1
|
||||
@@ -437,6 +437,95 @@ importers:
|
||||
specifier: ^4.75.0
|
||||
version: 4.75.0
|
||||
|
||||
apps/futo-backups-survey.immich.app:
|
||||
dependencies:
|
||||
'@immich/ui':
|
||||
specifier: ^0.65.0
|
||||
version: 0.65.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(tailwindcss@4.2.2)
|
||||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
svelte-turnstile:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(svelte@5.53.11)
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.1(eslint@10.0.3(jiti@2.6.1))
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.43.2
|
||||
version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^6.2.4
|
||||
version: 6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.13
|
||||
version: 4.2.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@types/node':
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
eslint:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.3(jiti@2.6.1)
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@10.0.3(jiti@2.6.1))
|
||||
eslint-plugin-svelte:
|
||||
specifier: ^3.12.4
|
||||
version: 3.15.2(eslint@10.0.3(jiti@2.6.1))(svelte@5.53.11)
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.4.0
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.8.1
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.4.0
|
||||
version: 3.5.1(prettier@3.8.1)(svelte@5.53.11)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.11))(prettier@3.8.1)
|
||||
svelte:
|
||||
specifier: ^5.39.5
|
||||
version: 5.53.11
|
||||
svelte-check:
|
||||
specifier: ^4.3.2
|
||||
version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.13
|
||||
version: 4.2.2
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.44.1
|
||||
version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.1.7
|
||||
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vitest:
|
||||
specifier: ^4.0.12
|
||||
version: 4.1.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
apps/futo-backups-survey.immich.app/backend:
|
||||
dependencies:
|
||||
itty-router:
|
||||
specifier: ^5.0.18
|
||||
version: 5.0.23
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
wrangler:
|
||||
specifier: ^4.75.0
|
||||
version: 4.75.0
|
||||
|
||||
apps/get.immich.app:
|
||||
dependencies:
|
||||
'@immich/ui':
|
||||
@@ -1338,6 +1427,144 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.9':
|
||||
resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.60.0':
|
||||
resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.60.0':
|
||||
resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.60.0':
|
||||
resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.60.0':
|
||||
resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.60.0':
|
||||
resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.60.0':
|
||||
resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
|
||||
resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
|
||||
resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.0':
|
||||
resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.0':
|
||||
resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.60.0':
|
||||
resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.60.0':
|
||||
resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.60.0':
|
||||
resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.60.0':
|
||||
resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||
resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1378,6 +1605,21 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2':
|
||||
resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==}
|
||||
engines: {node: ^20.19 || ^22.12 || >=24}
|
||||
peerDependencies:
|
||||
'@sveltejs/vite-plugin-svelte': ^6.0.0-next.0
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || ^7.0.0
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4':
|
||||
resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==}
|
||||
engines: {node: ^20.19 || ^22.12 || >=24}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || ^7.0.0
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@7.0.0':
|
||||
resolution: {integrity: sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==}
|
||||
engines: {node: ^20.19 || ^22.12 || >=24}
|
||||
@@ -2306,6 +2548,11 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rollup@4.60.0:
|
||||
resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
runed@0.35.1:
|
||||
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
|
||||
peerDependencies:
|
||||
@@ -2507,6 +2754,46 @@ packages:
|
||||
resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vite@8.0.0:
|
||||
resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3001,6 +3288,38 @@ snapshots:
|
||||
- '@sveltejs/kit'
|
||||
- tailwindcss
|
||||
|
||||
'@immich/ui@0.65.1(svelte@5.53.11)(tailwindcss@4.2.2)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.11)
|
||||
'@internationalized/date': 3.12.0
|
||||
'@mdi/js': 7.4.47
|
||||
bits-ui: 2.16.3(@internationalized/date@3.12.0)(svelte@5.53.11)
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.11.0
|
||||
svelte: 5.53.11
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.5.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2)
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
- tailwindcss
|
||||
|
||||
'@immich/ui@0.65.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(tailwindcss@4.2.2)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.11)
|
||||
'@internationalized/date': 3.12.0
|
||||
'@mdi/js': 7.4.47
|
||||
bits-ui: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.11.0
|
||||
svelte: 5.53.11
|
||||
svelte-highlight: 7.9.0
|
||||
tailwind-merge: 3.5.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.2)
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
- tailwindcss
|
||||
|
||||
'@immich/ui@0.65.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(tailwindcss@4.2.2)':
|
||||
dependencies:
|
||||
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.11)
|
||||
@@ -3121,6 +3440,81 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.9': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.0':
|
||||
optional: true
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@sindresorhus/is@7.2.0': {}
|
||||
@@ -3133,10 +3527,34 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))':
|
||||
dependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))
|
||||
|
||||
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@types/cookie': 0.6.0
|
||||
acorn: 8.16.0
|
||||
cookie: 0.6.0
|
||||
devalue: 5.6.4
|
||||
esm-env: 1.2.2
|
||||
kleur: 4.1.5
|
||||
magic-string: 0.30.21
|
||||
mrmime: 2.0.1
|
||||
set-cookie-parser: 3.0.1
|
||||
sirv: 3.0.2
|
||||
svelte: 5.53.11
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -3157,6 +3575,23 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
obug: 2.1.1
|
||||
svelte: 5.53.11
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
deepmerge: 4.3.1
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
svelte: 5.53.11
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
@@ -3231,6 +3666,13 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
||||
|
||||
'@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
tailwindcss: 4.2.2
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
'@tailwindcss/vite@4.2.2(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
@@ -3399,6 +3841,14 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
@@ -3459,6 +3909,19 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@internationalized/date': 3.12.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)
|
||||
svelte: 5.53.11
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)
|
||||
tabbable: 6.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
@@ -3472,6 +3935,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
bits-ui@2.16.3(@internationalized/date@3.12.0)(svelte@5.53.11):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@internationalized/date': 3.12.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(svelte@5.53.11)
|
||||
svelte: 5.53.11
|
||||
svelte-toolbelt: 0.10.6(svelte@5.53.11)
|
||||
tabbable: 6.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
brace-expansion@5.0.4:
|
||||
@@ -4008,6 +4484,46 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9
|
||||
|
||||
rollup@4.60.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.60.0
|
||||
'@rollup/rollup-android-arm64': 4.60.0
|
||||
'@rollup/rollup-darwin-arm64': 4.60.0
|
||||
'@rollup/rollup-darwin-x64': 4.60.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.60.0
|
||||
'@rollup/rollup-freebsd-x64': 4.60.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.60.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.60.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.60.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-loong64-musl': 4.60.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.60.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.60.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.60.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.60.0
|
||||
'@rollup/rollup-openbsd-x64': 4.60.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.60.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.60.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.60.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.60.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
esm-env: 1.2.2
|
||||
lz-string: 1.5.0
|
||||
svelte: 5.53.11
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -4017,6 +4533,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))
|
||||
|
||||
runed@0.35.1(svelte@5.53.11):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
esm-env: 1.2.2
|
||||
lz-string: 1.5.0
|
||||
svelte: 5.53.11
|
||||
|
||||
sade@1.8.1:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
@@ -4120,6 +4643,15 @@ snapshots:
|
||||
dependencies:
|
||||
highlight.js: 11.11.1
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.53.11)
|
||||
style-to-object: 1.0.14
|
||||
svelte: 5.53.11
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.11)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)))(svelte@5.53.11):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@@ -4129,6 +4661,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte-toolbelt@0.10.6(svelte@5.53.11):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.35.1(svelte@5.53.11)
|
||||
style-to-object: 1.0.14
|
||||
svelte: 5.53.11
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte-turnstile@0.11.0(svelte@5.53.11):
|
||||
dependencies:
|
||||
svelte: 5.53.11
|
||||
@@ -4223,6 +4764,20 @@ snapshots:
|
||||
|
||||
validator@13.15.23: {}
|
||||
|
||||
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
|
||||
vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
@@ -4237,6 +4792,10 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)):
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
vitefu@1.1.2(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)
|
||||
@@ -4247,6 +4806,33 @@ snapshots:
|
||||
svelte: 5.53.11
|
||||
vitest: 4.1.0(@types/node@24.12.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1))
|
||||
|
||||
vitest@4.1.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
'@vitest/spy': 4.1.0
|
||||
'@vitest/utils': 4.1.0
|
||||
es-module-lexer: 2.0.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 4.0.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.0(@types/node@24.12.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- apps/datasets.immich.app/backend
|
||||
- apps/futo-backups-survey.immich.app/backend
|
||||
|
||||
dedupePeerDependents: true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user