mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
chore: remove futo-backup-survey app (#665)
This commit is contained in:
@@ -77,7 +77,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
env:
|
||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# Keep app_name in sync with the deploy matrix in build.yml.
|
||||
matrix:
|
||||
environment: ['dev', 'prod']
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||
app_name: ['root', 'ui', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
||||
env:
|
||||
ENVIRONMENT: ${{ matrix.environment }}
|
||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist
|
||||
.wrangler/
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "futo-backups-survey-backend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"build": "wrangler build",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"wrangler": "^4.75.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"itty-router": "^5.0.18"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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(String.raw`(?:^|;\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`);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/* eslint-disable unicorn/no-top-level-side-effects */
|
||||
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]>({
|
||||
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/batch', async (request, env) => {
|
||||
const db = env.DB;
|
||||
const { answers } = (await request.json()) as {
|
||||
answers: Array<{ questionId: string; value: string; otherText?: string }>;
|
||||
};
|
||||
|
||||
if (!answers || !Array.isArray(answers) || answers.length === 0 || answers.length > 20) {
|
||||
return new Response('Invalid answers payload', { status: 400 });
|
||||
}
|
||||
|
||||
const respondentId = getRespondentId(request);
|
||||
if (!respondentId) {
|
||||
return new Response('No respondent cookie', { status: 400 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const statements = answers.map((a) =>
|
||||
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, a.questionId, a.value, a.otherText ?? null, now),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
|
||||
// @deprecated — kept for old clients still calling the single-answer endpoint
|
||||
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 (Object.hasOwn(answers, depId)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(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 () => {
|
||||
const headers = new Headers();
|
||||
deleteRespondentCookie(headers);
|
||||
return new Response(null, { status: 204, headers });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,27 +0,0 @@
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
conditional?: {
|
||||
showIf: { questionId: string; condition: 'skipped' };
|
||||
};
|
||||
}
|
||||
|
||||
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' },
|
||||
];
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2022"],
|
||||
"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"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
CF_TURNSTILE_SECRET: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "futo-backups.surveys.immich.app",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "svelte-kit sync && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"sync": "svelte-kit sync",
|
||||
"test": "vitest",
|
||||
"db:migrate:local": "cd backend && wrangler d1 migrations apply futo-backups-survey --local"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"globals": "^17.0.0",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"vite": "^8.0.2",
|
||||
"vitest": "^4.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@immich/ui": "^0.83.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"svelte-turnstile": "^0.11.0"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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 {};
|
||||
@@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,281 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
let bufferAnswer: typeof import('./api-client').bufferAnswer;
|
||||
let flushBuffer: typeof import('./api-client').flushBuffer;
|
||||
let flushBufferSync: typeof import('./api-client').flushBufferSync;
|
||||
let getBufferSize: typeof import('./api-client').getBufferSize;
|
||||
let fetchResume: typeof import('./api-client').fetchResume;
|
||||
let postComplete: typeof import('./api-client').postComplete;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
vi.resetModules();
|
||||
const mod = await import('./api-client');
|
||||
bufferAnswer = mod.bufferAnswer;
|
||||
flushBuffer = mod.flushBuffer;
|
||||
flushBufferSync = mod.flushBufferSync;
|
||||
getBufferSize = mod.getBufferSize;
|
||||
fetchResume = mod.fetchResume;
|
||||
postComplete = mod.postComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('bufferAnswer', () => {
|
||||
it('adds an answer to the buffer', () => {
|
||||
bufferAnswer({ questionId: 'q1', value: 'test' });
|
||||
expect(getBufferSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('overwrites duplicate questionIds', () => {
|
||||
bufferAnswer({ questionId: 'q1', value: 'first' });
|
||||
bufferAnswer({ questionId: 'q1', value: 'second' });
|
||||
expect(getBufferSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('buffers multiple different questions', () => {
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
bufferAnswer({ questionId: 'q3', value: 'c' });
|
||||
expect(getBufferSize()).toBe(3);
|
||||
});
|
||||
|
||||
it('auto-flushes after 4 answers', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
bufferAnswer({ questionId: 'q3', value: 'c' });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
bufferAnswer({ questionId: 'q4', value: 'd' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.answers).toHaveLength(4);
|
||||
expect(getBufferSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('auto-flushes after 10 seconds of inactivity', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.answers).toHaveLength(2);
|
||||
expect(getBufferSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('resets inactivity timer on each new answer', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
|
||||
// Wait 8 seconds, then add another answer
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
|
||||
// 8 more seconds — still within the new 10s window
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// 2 more seconds — now 10s since last answer
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets counter after threshold flush', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// First batch of 4
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
bufferAnswer({ questionId: 'q3', value: 'c' });
|
||||
bufferAnswer({ questionId: 'q4', value: 'd' });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Next 3 answers should NOT trigger a flush
|
||||
bufferAnswer({ questionId: 'q5', value: 'e' });
|
||||
bufferAnswer({ questionId: 'q6', value: 'f' });
|
||||
bufferAnswer({ questionId: 'q7', value: 'g' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 4th answer triggers second flush
|
||||
bufferAnswer({ questionId: 'q8', value: 'h' });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('duplicate answers do not inflate the flush counter', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Answer same question 4 times — should count as 4 towards threshold
|
||||
// (user changed their mind rapidly)
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q1', value: 'b' });
|
||||
bufferAnswer({ questionId: 'q1', value: 'c' });
|
||||
bufferAnswer({ questionId: 'q1', value: 'd' });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Threshold is based on answer events, not unique questions
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.answers).toHaveLength(1); // deduped in buffer
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushBuffer', () => {
|
||||
it('returns true immediately when buffer is empty', async () => {
|
||||
const result = await flushBuffer();
|
||||
expect(result).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends buffered answers to /api/answers/batch', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
bufferAnswer({ questionId: 'q2', value: 'b' });
|
||||
|
||||
const promise = flushBuffer();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/answers/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
});
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.answers).toHaveLength(2);
|
||||
expect(getBufferSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('re-adds items to buffer on failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
|
||||
const promise = flushBuffer();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(getBufferSize()).toBe(1);
|
||||
});
|
||||
|
||||
it('retries on 500 response - 4 attempts total', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
|
||||
const promise = flushBuffer();
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does NOT retry on 400 response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
|
||||
const promise = flushBuffer();
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not overwrite newer buffer entries on failure', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'old' });
|
||||
|
||||
const promise = flushBuffer();
|
||||
|
||||
// Buffer a newer answer while flush is in progress
|
||||
bufferAnswer({ questionId: 'q1', value: 'new' });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
// The buffer should still have the newer value, not the failed old one
|
||||
expect(getBufferSize()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushBufferSync', () => {
|
||||
it('uses sendBeacon when buffer has items', () => {
|
||||
const mockSendBeacon = vi.fn().mockReturnValue(true);
|
||||
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon });
|
||||
|
||||
bufferAnswer({ questionId: 'q1', value: 'a' });
|
||||
flushBufferSync();
|
||||
|
||||
expect(mockSendBeacon).toHaveBeenCalledWith('/api/answers/batch', expect.any(Blob));
|
||||
expect(getBufferSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing when buffer is empty', () => {
|
||||
const mockSendBeacon = vi.fn();
|
||||
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon });
|
||||
|
||||
flushBufferSync();
|
||||
|
||||
expect(mockSendBeacon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchResume', () => {
|
||||
it('calls GET /api/resume', 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');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postComplete', () => {
|
||||
it('calls POST /api/complete', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||
|
||||
await postComplete();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/complete', { method: 'POST' });
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 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 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Challenge verification failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingSave {
|
||||
questionId: string;
|
||||
value: string;
|
||||
otherText?: string;
|
||||
}
|
||||
|
||||
const answerBuffer: Map<string, PendingSave> = new Map();
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let unflushedCount = 0;
|
||||
|
||||
const BACKOFF_DELAYS = [1000, 2000, 4000];
|
||||
const INACTIVITY_MS = 10_000;
|
||||
const FLUSH_THRESHOLD = 4;
|
||||
|
||||
let onSaveErrorCallback: ((message: string) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Registers a callback invoked when a batch save fails after all retries.
|
||||
*/
|
||||
export function onSaveError(cb: (message: string) => void): void {
|
||||
onSaveErrorCallback = cb;
|
||||
}
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
}
|
||||
inactivityTimer = setTimeout(() => {
|
||||
flushBuffer();
|
||||
}, INACTIVITY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers an answer for later batch submission.
|
||||
* Auto-flushes after 4 answers or 10 seconds of inactivity.
|
||||
*/
|
||||
export function bufferAnswer(data: PendingSave): void {
|
||||
answerBuffer.set(data.questionId, data);
|
||||
unflushedCount++;
|
||||
resetInactivityTimer();
|
||||
|
||||
if (unflushedCount >= FLUSH_THRESHOLD) {
|
||||
flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a batch of answers to the server with exponential backoff retry.
|
||||
*/
|
||||
async function saveBatch(answers: PendingSave[]): Promise<boolean> {
|
||||
for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) {
|
||||
try {
|
||||
const res = await fetch('/api/answers/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answers }),
|
||||
});
|
||||
if (res.ok) {
|
||||
return true;
|
||||
}
|
||||
if (res.status < 500) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// network error, retry
|
||||
}
|
||||
if (attempt < BACKOFF_DELAYS.length) {
|
||||
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[attempt]));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the answer buffer to the server as a batch.
|
||||
* Returns true on success, false on failure.
|
||||
* On failure, items are re-added to the buffer for the next flush attempt.
|
||||
*/
|
||||
export async function flushBuffer(): Promise<boolean> {
|
||||
if (answerBuffer.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
}
|
||||
unflushedCount = 0;
|
||||
|
||||
const batch = answerBuffer.values().toArray();
|
||||
answerBuffer.clear();
|
||||
|
||||
const success = await saveBatch(batch);
|
||||
if (!success) {
|
||||
// Re-add failed items, but don't overwrite newer entries
|
||||
for (const item of batch) {
|
||||
if (!answerBuffer.has(item.questionId)) {
|
||||
answerBuffer.set(item.questionId, item);
|
||||
}
|
||||
}
|
||||
onSaveErrorCallback?.('Failed to save answers. Your responses will be retried automatically.');
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush for beforeunload — uses sendBeacon.
|
||||
*/
|
||||
export function flushBufferSync(): void {
|
||||
if (answerBuffer.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inactivityTimer !== null) {
|
||||
clearTimeout(inactivityTimer);
|
||||
inactivityTimer = null;
|
||||
}
|
||||
unflushedCount = 0;
|
||||
|
||||
const batch = answerBuffer.values().toArray();
|
||||
answerBuffer.clear();
|
||||
|
||||
const blob = new Blob([JSON.stringify({ answers: batch })], { type: 'application/json' });
|
||||
navigator.sendBeacon('/api/answers/batch', blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current buffer size (for testing).
|
||||
*/
|
||||
export function getBufferSize(): number {
|
||||
return answerBuffer.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
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',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to submit survey (${res.status})`);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@immich/ui/theme/default.css';
|
||||
@import '$common/theme/theme.css';
|
||||
@source '../../../../packages/ui/src';
|
||||
@source '../../../../common';
|
||||
|
||||
@theme inline {
|
||||
--color-immich-primary: rgb(var(--immich-primary));
|
||||
--color-immich-primary-10: rgb(var(--immich-primary) / 0.1);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, Logo } from '@immich/ui';
|
||||
|
||||
async function startOver() {
|
||||
await fetch('/api/reset', { method: 'POST' });
|
||||
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>
|
||||
@@ -1,36 +0,0 @@
|
||||
<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 = $derived(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}
|
||||
/>
|
||||
@@ -1,93 +0,0 @@
|
||||
<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) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
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, i (i)}
|
||||
<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 size-6 shrink-0 items-center justify-center rounded-sm {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>
|
||||
@@ -1,69 +0,0 @@
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonText = $derived.by(() => {
|
||||
if (isLast) {
|
||||
return 'Submit';
|
||||
}
|
||||
|
||||
if (hasAnswer || !isOptional) {
|
||||
return 'Next';
|
||||
}
|
||||
|
||||
return 'Skip';
|
||||
});
|
||||
</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 pt-4 pb-6">
|
||||
<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}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { SurveyAnswer, SurveyQuestion } from '$lib/types';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheck } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
question: SurveyQuestion;
|
||||
answer?: SurveyAnswer;
|
||||
onAnswer: (value: string, otherText?: string) => void;
|
||||
}
|
||||
|
||||
let { question, answer, onAnswer }: Props = $props();
|
||||
let otherText = $derived(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}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<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, i (i)}
|
||||
<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 size-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 size-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>
|
||||
@@ -1,38 +0,0 @@
|
||||
<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 tracking-wider text-gray-400 uppercase">
|
||||
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 transition-colors hover:text-gray-300">
|
||||
Skip this section
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,155 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { questions, sections } from '$lib/survey-definition';
|
||||
import type { createSurveyEngine } from '$lib/survey-engine.svelte';
|
||||
import { findNextVisibleIndex } from '$lib/survey-engine.svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
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);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
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="bg-immich-primary h-full 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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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 = $derived(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>
|
||||
@@ -1,53 +0,0 @@
|
||||
<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>
|
||||
@@ -1,47 +0,0 @@
|
||||
<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-160 flex-col gap-4 text-base">
|
||||
<h1 class="text-3xl font-bold">FUTO Backups</h1>
|
||||
|
||||
<p class="text-gray-300">
|
||||
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-300">This survey is broken down in 6 sections;</p>
|
||||
|
||||
<ol class="list-decimal space-y-1 pl-6 text-gray-300">
|
||||
<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}
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
on:callback={(e) => verifyTurnstile(e.detail.token).catch(() => {})}
|
||||
on:error={() => {}}
|
||||
/>
|
||||
|
||||
<Button color="primary" onclick={onStart}>Get Started</Button>
|
||||
</div>
|
||||
@@ -1,83 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,352 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
];
|
||||
@@ -1,126 +0,0 @@
|
||||
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 !Object.hasOwn(answers, q.conditional.showIf.questionId);
|
||||
}
|
||||
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 !== -1) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { questions } from './survey-definition';
|
||||
import type { SurveyAnswer } 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);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
@@ -1 +0,0 @@
|
||||
export const ssr = false;
|
||||
@@ -1,117 +0,0 @@
|
||||
<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,
|
||||
bufferAnswer,
|
||||
flushBuffer,
|
||||
flushBufferSync,
|
||||
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;
|
||||
// eslint-disable-next-line unicorn/prefer-await
|
||||
verifyTurnstile(token).catch(() => {});
|
||||
}
|
||||
|
||||
const handleUnload = () => flushBufferSync();
|
||||
|
||||
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 (error_) {
|
||||
error = error_ instanceof Error ? error_.message : 'Something went wrong. Please try again later.';
|
||||
}
|
||||
loading = false;
|
||||
})();
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleUnload);
|
||||
});
|
||||
|
||||
function handleAnswer(questionId: string, value: string, otherText?: string) {
|
||||
engine.setAnswer(questionId, value, otherText);
|
||||
bufferAnswer({ questionId, value, otherText });
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
try {
|
||||
const flushed = await flushBuffer();
|
||||
if (!flushed) {
|
||||
error = 'Failed to save your answers. Please try again.';
|
||||
return;
|
||||
}
|
||||
await postComplete();
|
||||
surveyFinished = true;
|
||||
} catch (error_) {
|
||||
error = error_ instanceof Error ? error_.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}
|
||||
@@ -1,198 +0,0 @@
|
||||
# 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)_
|
||||
@@ -1,21 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
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/*',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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": "../../"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@immich/ui', '@immich/svelte-markdown-preprocess'],
|
||||
},
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
},
|
||||
});
|
||||
@@ -19,7 +19,7 @@ March was a great month for the team. Most of us were able to fly out to Austin,
|
||||
|
||||
## FUTO backups survey
|
||||
|
||||
FUTO started building an encrypted backup service this year and they plan to integrate it directly into Immich. They are currently running a [survey](https://futo-backups-survey.immich.app/) to gather information about setups, library sizes, hardware & network capabilities, etc. Please fill out the survey if you haven't already. Also, if you are interested in joining the closed beta later this year there is a place to leave your email address.
|
||||
FUTO started building an encrypted backup service this year and they plan to integrate it directly into Immich. ~~They are currently running a [survey](https://futo-backups-survey.immich.app/) to gather information about setups, library sizes, hardware & network capabilities, etc. Please fill out the survey if you haven't already. Also, if you are interested in joining the closed beta later this year there is a place to leave your email address.~~
|
||||
|
||||
## Don't Be Evil
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
terraform {
|
||||
backend "pg" {}
|
||||
required_version = "~> 1.7"
|
||||
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "4.52.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
locals {
|
||||
resource_stage = var.stage != "" ? "-${var.stage}" : ""
|
||||
resource_env = "-${var.env}"
|
||||
resource_suffix = "${local.resource_env}${local.resource_stage}"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
provider "cloudflare" {
|
||||
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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}"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
variable "tf_state_postgres_conn_str" {}
|
||||
variable "stage" {}
|
||||
variable "env" {}
|
||||
variable "app_name" {}
|
||||
variable "cloudflare_account_id" {}
|
||||
variable "dist_dir" {}
|
||||
variable "migrations_dir" {}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
-123
@@ -398,77 +398,6 @@ importers:
|
||||
specifier: ^4.75.0
|
||||
version: 4.110.0(@cloudflare/workers-types@5.20260713.1)
|
||||
|
||||
apps/futo-backups-survey.immich.app:
|
||||
dependencies:
|
||||
'@immich/ui':
|
||||
specifier: ^0.83.0
|
||||
version: 0.83.0(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(tailwindcss@4.3.2)
|
||||
'@mdi/js':
|
||||
specifier: ^7.4.47
|
||||
version: 7.4.47
|
||||
svelte-turnstile:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
devDependencies:
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.10(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.43.2
|
||||
version: 2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^7.0.0
|
||||
version: 7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.3.2(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
'@types/node':
|
||||
specifier: ^24.13.3
|
||||
version: 24.13.3
|
||||
'@typescript/native':
|
||||
specifier: npm:typescript@^7.0.2
|
||||
version: typescript@7.0.2
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.4.2
|
||||
globals:
|
||||
specifier: ^17.0.0
|
||||
version: 17.7.0
|
||||
svelte:
|
||||
specifier: ^5.39.5
|
||||
version: 5.56.4(@typescript-eslint/types@8.63.0)
|
||||
svelte-check:
|
||||
specifier: ^4.3.2
|
||||
version: 4.7.2(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
tailwindcss:
|
||||
specifier: ^4.2.2
|
||||
version: 4.3.2
|
||||
typescript:
|
||||
specifier: npm:@typescript/typescript6@^6.0.2
|
||||
version: '@typescript/typescript6@6.0.2'
|
||||
vite:
|
||||
specifier: ^8.0.2
|
||||
version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
|
||||
apps/futo-backups-survey.immich.app/backend:
|
||||
dependencies:
|
||||
itty-router:
|
||||
specifier: ^5.0.18
|
||||
version: 5.0.24
|
||||
devDependencies:
|
||||
'@typescript/native':
|
||||
specifier: npm:typescript@^7.0.2
|
||||
version: typescript@7.0.2
|
||||
typescript:
|
||||
specifier: npm:@typescript/typescript6@^6.0.2
|
||||
version: '@typescript/typescript6@6.0.2'
|
||||
wrangler:
|
||||
specifier: ^4.75.0
|
||||
version: 4.110.0(@cloudflare/workers-types@5.20260713.1)
|
||||
|
||||
apps/get.immich.app:
|
||||
dependencies:
|
||||
'@immich/ui':
|
||||
@@ -1672,12 +1601,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@immich/ui@0.83.0':
|
||||
resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
|
||||
|
||||
@@ -5070,21 +4993,6 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.35.3':
|
||||
optional: true
|
||||
|
||||
'@immich/ui@0.83.0(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))(tailwindcss@4.3.2)':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.2
|
||||
'@mdi/js': 7.4.47
|
||||
'@sveltejs/kit': 2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
luxon: 3.7.2
|
||||
simple-icons: 16.25.0
|
||||
svelte: 5.56.4(@typescript-eslint/types@8.63.0)
|
||||
svelte-highlight: 7.14.1
|
||||
tailwind-merge: 3.6.0
|
||||
tailwind-variants: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.2)
|
||||
transitivePeerDependencies:
|
||||
- tailwindcss
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
@@ -5936,19 +5844,6 @@ snapshots:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
|
||||
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
'@floating-ui/dom': 1.7.6
|
||||
'@internationalized/date': 3.12.2
|
||||
esm-env: 1.2.2
|
||||
runed: 0.35.1(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
svelte: 5.56.4(@typescript-eslint/types@8.63.0)
|
||||
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
tabbable: 6.4.0
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.5
|
||||
@@ -7342,15 +7237,6 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.1.5
|
||||
'@rolldown/binding-win32-x64-msvc': 1.1.5
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
esm-env: 1.2.2
|
||||
lz-string: 1.5.0
|
||||
svelte: 5.56.4(@typescript-eslint/types@8.63.0)
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -7576,15 +7462,6 @@ snapshots:
|
||||
highlight.js: 11.11.1
|
||||
magic-string: 0.30.21
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.35.1(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0))
|
||||
style-to-object: 1.0.14
|
||||
svelte: 5.56.4(@typescript-eslint/types@8.63.0)
|
||||
transitivePeerDependencies:
|
||||
- '@sveltejs/kit'
|
||||
|
||||
svelte-toolbelt@0.10.6(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.4(@typescript-eslint/types@8.63.0))(vite@8.1.4(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.63.0)):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
Reference in New Issue
Block a user