mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 21:27:54 +08:00
feat: add slopped fubar survey
This commit is contained in:
+29
-14
@@ -22,11 +22,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||||
|
include:
|
||||||
|
- app_name: futo-backups-survey
|
||||||
|
app_dir: futo-backups.surveys.immich.app
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/${{ matrix.app_name }}.immich.app
|
working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -58,7 +61,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: build-frontend-output-${{ matrix.app_name }}
|
name: build-frontend-output-${{ matrix.app_name }}
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
path: apps/${{ matrix.app_name }}.immich.app/build
|
path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/${{ matrix.build_path || 'build' }}
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
build-backend:
|
build-backend:
|
||||||
@@ -66,11 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
app_name: ['datasets']
|
app_name: ['datasets', 'futo-backups-survey']
|
||||||
|
include:
|
||||||
|
- app_name: futo-backups-survey
|
||||||
|
app_dir: futo-backups.surveys.immich.app
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: apps/${{ matrix.app_name }}.immich.app/backend
|
working-directory: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -98,7 +104,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
with:
|
with:
|
||||||
name: build-backend-output-${{ matrix.app_name }}
|
name: build-backend-output-${{ matrix.app_name }}
|
||||||
path: 'apps/${{ matrix.app_name }}.immich.app/backend/dist'
|
path: apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/dist
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
@@ -108,11 +114,15 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets']
|
app_name: ['root', 'api', 'awesome', 'my', 'get', 'buy', 'datasets', 'futo-backups-survey']
|
||||||
|
include:
|
||||||
|
- app_name: futo-backups-survey
|
||||||
|
app_dir: futo-backups.surveys.immich.app
|
||||||
env:
|
env:
|
||||||
TF_VAR_app_name: ${{ matrix.app_name }}
|
TF_VAR_app_name: ${{ matrix.app_name }}
|
||||||
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
|
TF_VAR_stage: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
|
||||||
TF_VAR_dist_dir: ${{github.workspace}}/dist/backend
|
TF_VAR_dist_dir: ${{ github.workspace }}/dist/backend
|
||||||
|
TF_VAR_migrations_dir: ${{ github.workspace }}/apps/${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}/backend/migrations
|
||||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.OP_TF_PROD_ENV || secrets.OP_TF_DEV_ENV }}
|
OP_SERVICE_ACCOUNT_TOKEN: ${{ github.ref == 'refs/heads/main' && secrets.OP_TF_PROD_ENV || secrets.OP_TF_DEV_ENV }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -129,7 +139,8 @@ jobs:
|
|||||||
- name: Check if the app has a backend folder
|
- name: Check if the app has a backend folder
|
||||||
id: check-backend
|
id: check-backend
|
||||||
run: |
|
run: |
|
||||||
if [ -d "apps/${{ matrix.app_name }}.immich.app/backend" ]; then
|
APP_DIR="${{ matrix.app_dir || format('{0}.immich.app', matrix.app_name) }}"
|
||||||
|
if [ -d "apps/${APP_DIR}/backend" ]; then
|
||||||
echo "has_backend=true" >> $GITHUB_ENV
|
echo "has_backend=true" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "has_backend=false" >> $GITHUB_ENV
|
echo "has_backend=false" >> $GITHUB_ENV
|
||||||
@@ -164,11 +175,15 @@ jobs:
|
|||||||
INCLUDE_DIRS='**/workers/${{ matrix.app_name }}'
|
INCLUDE_DIRS='**/workers/${{ matrix.app_name }}'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
QUEUE_ARGS="--queue-include-dir='**/!(workers)/**'"
|
||||||
|
if [ -n "$INCLUDE_DIRS" ]; then
|
||||||
|
QUEUE_ARGS="$QUEUE_ARGS --queue-include-dir="$INCLUDE_DIRS""
|
||||||
|
fi
|
||||||
|
|
||||||
op run --env-file='.env' -- terragrunt run --all apply \
|
op run --env-file='.env' -- terragrunt run --all apply \
|
||||||
--non-interactive \
|
--non-interactive \
|
||||||
--parallelism 1 \
|
--parallelism 1 \
|
||||||
--queue-include-dir='**/!(workers)/**' \
|
$QUEUE_ARGS
|
||||||
--queue-include-dir="$INCLUDE_DIRS"
|
|
||||||
|
|
||||||
- name: Cloudflare Deploy Output
|
- name: Cloudflare Deploy Output
|
||||||
id: deploy-output
|
id: deploy-output
|
||||||
@@ -181,16 +196,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
projectName: ${{ fromJson(steps.deploy-output.outputs.output).pages_project_name.value }}
|
projectName: ${{ fromJson(steps.deploy-output.outputs.output || '{}').pages_project_name.value }}
|
||||||
directory: 'dist/frontend'
|
directory: 'dist/frontend'
|
||||||
branch: ${{ fromJson(steps.deploy-output.outputs.output).pages_branch.value }}
|
branch: ${{ fromJson(steps.deploy-output.outputs.output || '{}').pages_branch.value }}
|
||||||
wranglerVersion: '3'
|
wranglerVersion: '3'
|
||||||
|
|
||||||
- name: Save preview URL
|
- name: Save preview URL
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
env:
|
env:
|
||||||
APP_NAME: ${{ matrix.app_name }}
|
APP_NAME: ${{ matrix.app_name }}
|
||||||
SUBDOMAIN: ${{ fromJson(steps.deploy-output.outputs.output).immich_subdomain.value }}
|
SUBDOMAIN: ${{ fromJson(steps.deploy-output.outputs.output || '{}').immich_subdomain.value }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/preview-urls
|
mkdir -p /tmp/preview-urls
|
||||||
echo "${APP_NAME}|${SUBDOMAIN}" > "/tmp/preview-urls/${APP_NAME}.txt"
|
echo "${APP_NAME}|${SUBDOMAIN}" > "/tmp/preview-urls/${APP_NAME}.txt"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ node_modules
|
|||||||
.vercel
|
.vercel
|
||||||
apps/*/.svelte-kit
|
apps/*/.svelte-kit
|
||||||
apps/*/build
|
apps/*/build
|
||||||
|
apps/*/.wrangler
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -19,3 +20,7 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Planning
|
||||||
|
.planning/
|
||||||
|
apps/*/.planning
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist
|
||||||
|
.wrangler/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- migrations/0001_initial.sql
|
||||||
|
-- Initial schema: respondents and answers tables for survey persistence
|
||||||
|
|
||||||
|
CREATE TABLE respondents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
is_complete INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_respondents_ip ON respondents(ip_address);
|
||||||
|
|
||||||
|
CREATE TABLE answers (
|
||||||
|
respondent_id TEXT NOT NULL,
|
||||||
|
question_id TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
answered_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (respondent_id, question_id),
|
||||||
|
FOREIGN KEY (respondent_id) REFERENCES respondents(id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- migrations/0002_cookie_dedup.sql
|
||||||
|
-- Switch from IP-based to cookie-based deduplication.
|
||||||
|
-- IP column stays for analytics but is no longer a uniqueness constraint.
|
||||||
|
-- Add other_text column for "Other" free-text responses on Q7, Q13-Q16.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_respondents_ip;
|
||||||
|
|
||||||
|
ALTER TABLE answers ADD COLUMN other_text TEXT;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "futo-backups-survey-backend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"cf-typegen": "wrangler types"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"wrangler": "^4.75.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"itty-router": "^5.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const COOKIE_NAME = 'respondent_id';
|
||||||
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 90; // 90 days
|
||||||
|
|
||||||
|
export function getRespondentId(request: Request): string | undefined {
|
||||||
|
const header = request.headers.get('Cookie') ?? '';
|
||||||
|
const match = header.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRespondentCookie(headers: Headers, respondentId: string): void {
|
||||||
|
headers.set(
|
||||||
|
'Set-Cookie',
|
||||||
|
`${COOKIE_NAME}=${respondentId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRespondentCookie(headers: Headers): void {
|
||||||
|
headers.set(
|
||||||
|
'Set-Cookie',
|
||||||
|
`${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { AutoRouter, cors, IRequest } from 'itty-router';
|
||||||
|
import { getRespondentId, setRespondentCookie, deleteRespondentCookie } from './cookie';
|
||||||
|
import { questions } from './survey-definition';
|
||||||
|
|
||||||
|
const { preflight, corsify } = cors();
|
||||||
|
|
||||||
|
const router = AutoRouter<IRequest, [Env, ExecutionContext]>({
|
||||||
|
before: [preflight],
|
||||||
|
finally: [corsify],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/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 FROM respondents WHERE id = ?')
|
||||||
|
.bind(respondentId)
|
||||||
|
.first<{ id: string; is_complete: number }>();
|
||||||
|
|
||||||
|
if (!respondent) {
|
||||||
|
return Response.json({ answers: {}, nextQuestionIndex: 0 }, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (respondent.is_complete) {
|
||||||
|
return Response.json({ isComplete: true }, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.prepare('SELECT question_id, answer, other_text FROM answers WHERE respondent_id = ?')
|
||||||
|
.bind(respondentId)
|
||||||
|
.all<{ question_id: string; answer: string; other_text: string | null }>();
|
||||||
|
|
||||||
|
const answers: Record<string, { value: string; otherText?: string }> = {};
|
||||||
|
for (const row of rows.results) {
|
||||||
|
answers[row.question_id] = {
|
||||||
|
value: row.answer,
|
||||||
|
...(row.other_text ? { otherText: row.other_text } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextQuestionIndex = questions.length;
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
|
||||||
|
if (q.conditional?.showIf.condition === 'skipped') {
|
||||||
|
const depId = q.conditional.showIf.questionId;
|
||||||
|
if (answers[depId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!answers[q.id]) {
|
||||||
|
nextQuestionIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ answers, nextQuestionIndex }, { headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/complete', async (request, env) => {
|
||||||
|
const db = env.DB;
|
||||||
|
const respondentId = getRespondentId(request);
|
||||||
|
|
||||||
|
if (!respondentId) {
|
||||||
|
return new Response('No respondent cookie', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ?')
|
||||||
|
.bind(new Date().toISOString(), respondentId)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/reset', async (request) => {
|
||||||
|
const headers = new Headers();
|
||||||
|
deleteRespondentCookie(headers);
|
||||||
|
return new Response(null, { status: 204, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
interface SurveyQuestion {
|
||||||
|
id: string;
|
||||||
|
conditional?: {
|
||||||
|
showIf: { questionId: string; condition: 'skipped' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const opt = (label: string) => ({ label, value: label });
|
||||||
|
|
||||||
|
export const questions: SurveyQuestion[] = [
|
||||||
|
{ id: 'q1' },
|
||||||
|
{ id: 'q2' },
|
||||||
|
{ id: 'q3' },
|
||||||
|
{ id: 'q4' },
|
||||||
|
{ id: 'q5' },
|
||||||
|
{ id: 'q6' },
|
||||||
|
{ id: 'q7' },
|
||||||
|
{ id: 'q8' },
|
||||||
|
{ id: 'q9' },
|
||||||
|
{ id: 'q10' },
|
||||||
|
{ id: 'q11' },
|
||||||
|
{ id: 'q12' },
|
||||||
|
{ id: 'q13' },
|
||||||
|
{ id: 'q14' },
|
||||||
|
{ id: 'q15' },
|
||||||
|
{ id: 'q16' },
|
||||||
|
{ id: 'q17' },
|
||||||
|
{
|
||||||
|
id: 'q18',
|
||||||
|
conditional: {
|
||||||
|
showIf: { questionId: 'q17', condition: 'skipped' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'q19' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2021",
|
||||||
|
"lib": ["es2021"],
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["./worker-configuration.d.ts"]
|
||||||
|
},
|
||||||
|
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
interface Env {
|
||||||
|
DB: D1Database;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "futo-backups-survey-api",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-06-03",
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "futo-backups-survey",
|
||||||
|
"database_id": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
prettier,
|
||||||
|
...svelte.configs['flat/prettier'],
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['build/', '.svelte-kit/', 'dist/'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "futo-backups.surveys.immich.app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "GNU Affero General Public License version 3",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "svelte-kit sync && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"sync": "svelte-kit sync",
|
||||||
|
"test": "vitest",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
|
"format": "prettier --check .",
|
||||||
|
"format:fix": "prettier --write .",
|
||||||
|
"db:migrate:local": "cd backend && wrangler d1 migrations apply futo-backups-survey --local"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.43.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"eslint": "^10.0.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.39.5",
|
||||||
|
"svelte-check": "^4.3.2",
|
||||||
|
"tailwindcss": "^4.1.13",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"typescript-eslint": "^8.44.1",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^4.0.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@immich/ui": "^0.65.0",
|
||||||
|
"@mdi/js": "^7.4.47"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>FUTO Backups Survey</title>
|
||||||
|
<meta name="theme-color" content="currentColor" />
|
||||||
|
<meta name="description" content="Help us build FUTO Backups for Immich - share your feedback" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover" class="bg-light text-dark dark">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Module will be imported after it exists
|
||||||
|
let saveAnswer: typeof import('./api-client').saveAnswer;
|
||||||
|
let fireAndForgetSave: typeof import('./api-client').fireAndForgetSave;
|
||||||
|
let flushPendingQueue: typeof import('./api-client').flushPendingQueue;
|
||||||
|
let getPendingQueue: typeof import('./api-client').getPendingQueue;
|
||||||
|
let fetchResume: typeof import('./api-client').fetchResume;
|
||||||
|
let postComplete: typeof import('./api-client').postComplete;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
// Re-import module fresh each test to reset pendingQueue
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./api-client');
|
||||||
|
saveAnswer = mod.saveAnswer;
|
||||||
|
fireAndForgetSave = mod.fireAndForgetSave;
|
||||||
|
flushPendingQueue = mod.flushPendingQueue;
|
||||||
|
getPendingQueue = mod.getPendingQueue;
|
||||||
|
fetchResume = mod.fetchResume;
|
||||||
|
postComplete = mod.postComplete;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveAnswer', () => {
|
||||||
|
it('calls fetch with correct body and headers', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const data = { questionId: 'q1', value: 'test' };
|
||||||
|
const promise = saveAnswer(data);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/answers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true on 204 response', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||||
|
|
||||||
|
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on 500 response - fetch called 4 times total', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT retry on 400 response - fetch called once', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400 });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries on network error (fetch throws)', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses delays of 1000, 2000, 4000 ms between retries', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const promise = saveAnswer({ questionId: 'q1', value: 'test' });
|
||||||
|
|
||||||
|
// Initial call happens immediately
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// After 1000ms: first retry
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// After 2000ms more: second retry
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// After 4000ms more: third retry
|
||||||
|
await vi.advanceTimersByTimeAsync(4000);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fireAndForgetSave', () => {
|
||||||
|
it('adds to pendingQueue on total failure', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||||
|
|
||||||
|
fireAndForgetSave({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
// Allow microtasks to settle
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(getPendingQueue()).toHaveLength(1);
|
||||||
|
expect(getPendingQueue()[0]).toEqual({ questionId: 'q1', value: 'test' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flushPendingQueue', () => {
|
||||||
|
it('re-fires queued items', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
// First: make a save fail to populate the queue
|
||||||
|
fireAndForgetSave({ questionId: 'q1', value: 'test' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getPendingQueue()).toHaveLength(1);
|
||||||
|
|
||||||
|
// Now make fetch succeed and flush
|
||||||
|
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
||||||
|
const callsBefore = mockFetch.mock.calls.length;
|
||||||
|
flushPendingQueue();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
// Queue should be empty now (re-fired successfully)
|
||||||
|
expect(getPendingQueue()).toHaveLength(0);
|
||||||
|
// fetch should have been called at least once more
|
||||||
|
expect(mockFetch.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchResume', () => {
|
||||||
|
it('calls GET /api/resume with credentials: same-origin', async () => {
|
||||||
|
const mockResponse = { answers: {}, nextQuestionIndex: 0 };
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await fetchResume();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/resume', { credentials: 'same-origin' });
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postComplete', () => {
|
||||||
|
it('calls POST /api/complete with credentials: same-origin', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }));
|
||||||
|
|
||||||
|
await postComplete();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { SurveyAnswer } from './types';
|
||||||
|
|
||||||
|
interface PendingSave {
|
||||||
|
questionId: string;
|
||||||
|
value: string;
|
||||||
|
otherText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingQueue: PendingSave[] = [];
|
||||||
|
|
||||||
|
const BACKOFF_DELAYS = [1000, 2000, 4000];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves an answer to the server with exponential backoff retry.
|
||||||
|
* Retries up to 3 times on server errors (5xx) and network errors.
|
||||||
|
* Does NOT retry on client errors (4xx).
|
||||||
|
*/
|
||||||
|
export async function saveAnswer(data: PendingSave): Promise<boolean> {
|
||||||
|
for (let attempt = 0; attempt <= BACKOFF_DELAYS.length; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/answers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (res.ok) return true;
|
||||||
|
if (res.status < 500) return false; // client error, don't retry
|
||||||
|
} catch {
|
||||||
|
// network error, retry
|
||||||
|
}
|
||||||
|
if (attempt < BACKOFF_DELAYS.length) {
|
||||||
|
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[attempt]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let onSaveErrorCallback: ((message: string) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback invoked when a save fails after all retries.
|
||||||
|
*/
|
||||||
|
export function onSaveError(cb: (message: string) => void): void {
|
||||||
|
onSaveErrorCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget: saves an answer without blocking.
|
||||||
|
* On total failure (all retries exhausted), queues for later retry and notifies via callback.
|
||||||
|
*/
|
||||||
|
export function fireAndForgetSave(data: PendingSave): void {
|
||||||
|
saveAnswer(data).then((success) => {
|
||||||
|
if (!success) {
|
||||||
|
pendingQueue.push(data);
|
||||||
|
onSaveErrorCallback?.('Failed to save answer. Your response will be retried automatically.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attempts all queued saves that previously failed.
|
||||||
|
*/
|
||||||
|
export function flushPendingQueue(): void {
|
||||||
|
const queue = [...pendingQueue];
|
||||||
|
pendingQueue = [];
|
||||||
|
for (const item of queue) {
|
||||||
|
fireAndForgetSave(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current pending queue (for testing).
|
||||||
|
*/
|
||||||
|
export function getPendingQueue(): PendingSave[] {
|
||||||
|
return pendingQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches resume data from the server.
|
||||||
|
* Returns existing answers and next question index, or isComplete flag.
|
||||||
|
*/
|
||||||
|
export async function fetchResume(): Promise<{
|
||||||
|
answers?: Record<string, { value: string; otherText?: string }>;
|
||||||
|
nextQuestionIndex?: number;
|
||||||
|
isComplete?: boolean;
|
||||||
|
}> {
|
||||||
|
const res = await fetch('/api/resume', { credentials: 'same-origin' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load survey (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the survey as complete on the server.
|
||||||
|
*/
|
||||||
|
export async function postComplete(): Promise<void> {
|
||||||
|
const res = await fetch('/api/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to submit survey (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import '@immich/ui/theme/default.css';
|
||||||
|
@import '$common/theme/theme.css';
|
||||||
|
@source '../../node_modules/@immich/ui/dist';
|
||||||
|
@source '../../../../common';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button, Logo } from '@immich/ui';
|
||||||
|
|
||||||
|
async function startOver() {
|
||||||
|
await fetch('/api/reset', { method: 'POST', credentials: 'same-origin' });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 text-center">
|
||||||
|
<Logo variant="stacked" size="large" />
|
||||||
|
|
||||||
|
<h1 class="text-3xl font-bold">You've already completed this survey</h1>
|
||||||
|
<p class="text-gray-500">Your responses were recorded. Thank you for your feedback!</p>
|
||||||
|
|
||||||
|
<Button variant="outline" onclick={startOver}>Start over</Button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '@immich/ui';
|
||||||
|
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: SurveyQuestion;
|
||||||
|
answer?: SurveyAnswer;
|
||||||
|
onAnswer: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { question, answer, onAnswer }: Props = $props();
|
||||||
|
let emailValue = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
emailValue = answer?.value ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const isValid = $derived(emailValue.length > 0 && emailPattern.test(emailValue));
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
if (isValid) {
|
||||||
|
onAnswer(emailValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||||
|
|
||||||
|
{#if question.description}
|
||||||
|
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={question.placeholder ?? 'name@example.com'}
|
||||||
|
bind:value={emailValue}
|
||||||
|
size="medium"
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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 TextareaQuestion from './TextareaQuestion.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: SurveyQuestion;
|
||||||
|
answer?: SurveyAnswer;
|
||||||
|
onAnswer: (value: string, otherText?: string) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
canGoBack: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { question, answer, onAnswer, onNext, onBack, canGoBack }: Props = $props();
|
||||||
|
|
||||||
|
const hasAnswer = $derived(!!answer?.value);
|
||||||
|
const isOptional = $derived(!question.required);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full max-w-[640px] px-4">
|
||||||
|
{#if question.type === 'radio'}
|
||||||
|
<RadioQuestion {question} {answer} {onAnswer} />
|
||||||
|
{:else if question.type === 'email'}
|
||||||
|
<EmailQuestion {question} {answer} onAnswer={(v) => onAnswer(v)} />
|
||||||
|
{:else if question.type === 'textarea'}
|
||||||
|
<TextareaQuestion {question} {answer} onAnswer={(v) => onAnswer(v)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-8 flex items-center gap-3">
|
||||||
|
{#if canGoBack}
|
||||||
|
<Button variant="outline" onclick={onBack}>Back</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={!hasAnswer && !isOptional}
|
||||||
|
onclick={onNext}
|
||||||
|
>
|
||||||
|
{hasAnswer || !isOptional ? 'Next' : 'Skip'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Icon, Input } from '@immich/ui';
|
||||||
|
import { mdiCheck } from '@mdi/js';
|
||||||
|
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: SurveyQuestion;
|
||||||
|
answer?: SurveyAnswer;
|
||||||
|
onAnswer: (value: string, otherText?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { question, answer, onAnswer }: Props = $props();
|
||||||
|
let otherText = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
otherText = answer?.otherText ?? '';
|
||||||
|
});
|
||||||
|
function select(value: string) {
|
||||||
|
if (value === 'Other' && question.hasOther) {
|
||||||
|
onAnswer(value, otherText);
|
||||||
|
} else {
|
||||||
|
onAnswer(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||||
|
|
||||||
|
{#if question.description}
|
||||||
|
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each (question.options ?? []).filter((o) => !question.hasOther || o.value !== 'Other') as option}
|
||||||
|
<button
|
||||||
|
class="flex min-h-12 w-full items-center gap-3 rounded-lg border-2 p-4 text-left transition-all duration-150
|
||||||
|
{answer?.value === option.value
|
||||||
|
? 'border-[var(--immich-primary)] bg-[var(--immich-primary)]/10'
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||||
|
onclick={() => select(option.value)}
|
||||||
|
>
|
||||||
|
{#if answer?.value === option.value}
|
||||||
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-[var(--immich-primary)] text-white">
|
||||||
|
<Icon icon={mdiCheck} size="18" />
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="h-4 w-4 shrink-0 rounded-full border-2 border-gray-300 dark:border-gray-500"></span>
|
||||||
|
{/if}
|
||||||
|
<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-[var(--immich-primary)] bg-[var(--immich-primary)]/10'
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
|
||||||
|
>
|
||||||
|
{#if answer?.value === 'Other'}
|
||||||
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-[var(--immich-primary)] text-white">
|
||||||
|
<Icon icon={mdiCheck} size="18" />
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="h-4 w-4 shrink-0 rounded-full border-2 border-gray-300 dark:border-gray-500"></span>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="flex-1 bg-transparent text-base outline-none placeholder:text-gray-400"
|
||||||
|
placeholder={question.otherPrompt ?? 'Other...'}
|
||||||
|
bind:value={otherText}
|
||||||
|
onfocus={() => onAnswer('Other', otherText)}
|
||||||
|
oninput={() => onAnswer('Other', otherText)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SurveySection } from '$lib/types';
|
||||||
|
import { Button } from '@immich/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: SurveySection;
|
||||||
|
onContinue: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
canGoBack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { section, onContinue, onBack, canGoBack = false }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
|
||||||
|
<div class="flex max-w-[640px] flex-col gap-4">
|
||||||
|
<span class="text-sm font-medium uppercase tracking-wider text-gray-400">
|
||||||
|
Section {section.number} of 5
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { sections } from '$lib/survey-definition';
|
||||||
|
import type { SurveySection } from '$lib/types';
|
||||||
|
import type { createSurveyEngine } from '$lib/survey-engine.svelte';
|
||||||
|
import QuestionCard from './QuestionCard.svelte';
|
||||||
|
import SectionHeader from './SectionHeader.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
engine: ReturnType<typeof createSurveyEngine>;
|
||||||
|
onAnswer: (questionId: string, value: string, otherText?: string) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { engine, onAnswer, onComplete }: Props = $props();
|
||||||
|
|
||||||
|
let direction = $state(1);
|
||||||
|
let transitioning = $state(false);
|
||||||
|
let showingSectionHeader = $state(false);
|
||||||
|
let currentSection = $state<SurveySection | null>(null);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const section = needsSectionHeader;
|
||||||
|
if (section && !dismissedSections.has(section.number)) {
|
||||||
|
showingSectionHeader = true;
|
||||||
|
currentSection = section;
|
||||||
|
} else {
|
||||||
|
showingSectionHeader = false;
|
||||||
|
currentSection = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismissSection() {
|
||||||
|
if (currentSection) {
|
||||||
|
dismissedSections.add(currentSection.number);
|
||||||
|
// Trigger reactivity by reassigning
|
||||||
|
dismissedSections = new Set(dismissedSections);
|
||||||
|
}
|
||||||
|
showingSectionHeader = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// On a question — check if going back crosses a section boundary
|
||||||
|
const currentQ = engine.currentQuestion;
|
||||||
|
engine.previous();
|
||||||
|
const prevQ = engine.currentQuestion;
|
||||||
|
if (currentQ && prevQ && currentQ.section !== prevQ.section) {
|
||||||
|
// Re-show the current section header when navigating back into it
|
||||||
|
dismissedSections.delete(prevQ.section);
|
||||||
|
dismissedSections = new Set(dismissedSections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
transitioning = false;
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative min-h-screen overflow-hidden">
|
||||||
|
<div class="fixed top-0 left-0 z-50 h-1 w-full bg-gray-200">
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--immich-primary)] transition-all duration-300"
|
||||||
|
style="width: {engine.progress}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-screen items-center justify-center">
|
||||||
|
{#key `${engine.currentIndex}-${showingSectionHeader}`}
|
||||||
|
<div
|
||||||
|
class="absolute 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}
|
||||||
|
canGoBack={currentSection.number > 1}
|
||||||
|
/>
|
||||||
|
{:else if engine.currentQuestion}
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Textarea } from '@immich/ui';
|
||||||
|
import type { SurveyQuestion, SurveyAnswer } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: SurveyQuestion;
|
||||||
|
answer?: SurveyAnswer;
|
||||||
|
onAnswer: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { question, answer, onAnswer }: Props = $props();
|
||||||
|
let textValue = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
textValue = answer?.value ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLength = $derived(question.maxLength ?? 5000);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (textValue.length > maxLength) {
|
||||||
|
textValue = textValue.slice(0, maxLength);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
onAnswer(textValue);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="mb-2 text-2xl font-bold">{question.text}</h2>
|
||||||
|
|
||||||
|
{#if question.description}
|
||||||
|
<p class="mb-6 text-base text-gray-500">{question.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
bind:value={textValue}
|
||||||
|
rows={6}
|
||||||
|
maxlength={maxLength}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="mt-1 text-right text-sm text-gray-400">{textValue.length}/{maxLength}</p>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Logo } from '@immich/ui';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 text-center">
|
||||||
|
<Logo variant="stacked" size="large" />
|
||||||
|
|
||||||
|
<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: 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: var(--immich-primary);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-dasharray: 48;
|
||||||
|
stroke-dashoffset: 48;
|
||||||
|
animation: stroke 300ms cubic-bezier(0.65, 0, 0.45, 1) 400ms forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stroke {
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button, Logo } from '@immich/ui';
|
||||||
|
|
||||||
|
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="large" />
|
||||||
|
|
||||||
|
<div class="flex max-w-[640px] flex-col gap-4 text-base">
|
||||||
|
<h1 class="text-3xl font-bold">FUTO Backups</h1>
|
||||||
|
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Hey, thanks for taking the time to fill out the FUTO Backups survey. We estimate this will
|
||||||
|
take a maximum of 5 minutes of your time. As a thanks for completing this survey, you can
|
||||||
|
provide your email at the end and we will invite you to the closed beta of the product when it
|
||||||
|
launches, which will be free for the duration of the beta period.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-gray-500">This survey is broken down in 5 sections;</p>
|
||||||
|
|
||||||
|
<ol class="list-decimal space-y-1 pl-6 text-gray-500">
|
||||||
|
<li>Questions about your immich server, your library and your internet speed</li>
|
||||||
|
<li>Your current backup solution</li>
|
||||||
|
<li>Questions about FUTO Backups - and your interest in it</li>
|
||||||
|
<li>Additional questions about how you host Immich</li>
|
||||||
|
<li>Private Beta Signup</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button color="primary" onclick={onStart}>Get Started</Button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { expect, test, describe } from 'vitest';
|
||||||
|
import { questions, sections } from './survey-definition';
|
||||||
|
|
||||||
|
describe('survey-definition', () => {
|
||||||
|
test('has exactly 19 questions', () => {
|
||||||
|
expect(questions).toHaveLength(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('questions have sequential ids q1 through q19', () => {
|
||||||
|
const ids = questions.map((q) => q.id);
|
||||||
|
expect(ids).toEqual(Array.from({ length: 19 }, (_, i) => `q${i + 1}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has exactly 5 sections', () => {
|
||||||
|
expect(sections).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 q13-q16', () => {
|
||||||
|
expect(sections[3].questionIds).toEqual(['q13', 'q14', 'q15', 'q16']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('section 5 contains q17-q19', () => {
|
||||||
|
expect(sections[4].questionIds).toEqual(['q17', 'q18', '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 and q18 are email type', () => {
|
||||||
|
expect(questions.find((q) => q.id === 'q17')?.type).toBe('email');
|
||||||
|
expect(questions.find((q) => q.id === 'q18')?.type).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('q18 has conditional: showIf q17 skipped', () => {
|
||||||
|
const q18 = questions.find((q) => q.id === 'q18');
|
||||||
|
expect(q18?.conditional).toEqual({
|
||||||
|
showIf: { questionId: 'q17', condition: 'skipped' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('q5, q12, q17, q18, q19 have descriptions', () => {
|
||||||
|
const descIds = ['q5', 'q12', 'q17', 'q18', 'q19'];
|
||||||
|
for (const id of descIds) {
|
||||||
|
const q = questions.find((q) => q.id === id);
|
||||||
|
expect(q?.description, `${id} should have a description`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no question has an empty text field', () => {
|
||||||
|
for (const q of questions) {
|
||||||
|
expect(q.text.length, `${q.id} should have non-empty text`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import type { SurveyQuestion, SurveySection } from './types';
|
||||||
|
|
||||||
|
const opt = (label: string) => ({ label, value: label });
|
||||||
|
|
||||||
|
export const questions: SurveyQuestion[] = [
|
||||||
|
// Section 1: Questions about your Immich server, your library and your internet speed
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
section: 1,
|
||||||
|
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||||
|
text: 'Where is your Immich server located?',
|
||||||
|
type: 'radio',
|
||||||
|
required: true,
|
||||||
|
hasOther: true,
|
||||||
|
otherPrompt: 'Please enter where your Immich server is located',
|
||||||
|
options: [
|
||||||
|
opt('North America - East (US East Coast, Eastern Canada)'),
|
||||||
|
opt('North America - Central (US Midwest, Central Canada)'),
|
||||||
|
opt('North America - West (US West Coast, Western Canada)'),
|
||||||
|
opt('South America'),
|
||||||
|
opt('Europe - West (UK, France, Netherlands, Nordics, etc.)'),
|
||||||
|
opt('Europe - East (Poland, Romania, Balkans, Baltics, etc.)'),
|
||||||
|
opt('Asia-Pacific - East (Japan, South Korea, Taiwan, etc.)'),
|
||||||
|
opt('Asia-Pacific - Southeast (Singapore, Australia, India, etc.)'),
|
||||||
|
opt('Middle East / Africa'),
|
||||||
|
opt('Other'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q2',
|
||||||
|
section: 1,
|
||||||
|
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||||
|
text: "What is your server's download speed?",
|
||||||
|
type: 'radio',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
opt('Less than 10Mbps'),
|
||||||
|
opt('10 - 25Mbps'),
|
||||||
|
opt('26 - 50Mbps'),
|
||||||
|
opt('51 - 100Mbps'),
|
||||||
|
opt('101 - 500Mbps'),
|
||||||
|
opt('501 - 1Gbps'),
|
||||||
|
opt('Over 1Gbps'),
|
||||||
|
opt("I'm not sure"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q3',
|
||||||
|
section: 1,
|
||||||
|
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||||
|
text: "What is your server's upload speed?",
|
||||||
|
type: 'radio',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
opt('Less than 10Mbps'),
|
||||||
|
opt('10 - 25Mbps'),
|
||||||
|
opt('26 - 50Mbps'),
|
||||||
|
opt('51 - 100Mbps'),
|
||||||
|
opt('101 - 500Mbps'),
|
||||||
|
opt('501 - 1Gbps'),
|
||||||
|
opt('Over 1Gbps'),
|
||||||
|
opt("I'm not sure"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q4',
|
||||||
|
section: 1,
|
||||||
|
sectionTitle: 'Questions about your Immich server, your library and your internet speed',
|
||||||
|
text: 'What is the total size of your Immich library?',
|
||||||
|
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: Additional questions about how you host Immich
|
||||||
|
{
|
||||||
|
id: 'q13',
|
||||||
|
section: 4,
|
||||||
|
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: 4,
|
||||||
|
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: 4,
|
||||||
|
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: 4,
|
||||||
|
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('Other'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Section 5: Private Beta Signup
|
||||||
|
{
|
||||||
|
id: 'q17',
|
||||||
|
section: 5,
|
||||||
|
sectionTitle: 'Beta Program',
|
||||||
|
text: 'Beta Program',
|
||||||
|
description:
|
||||||
|
"Leave your email address below and we'll reach out when the beta is ready.",
|
||||||
|
type: 'email',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'name@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q18',
|
||||||
|
section: 5,
|
||||||
|
sectionTitle: 'Beta Program',
|
||||||
|
text: 'Get Updates',
|
||||||
|
description:
|
||||||
|
"You didn't enroll in the beta program, if you would still like to receive product updates though, you can leave your email here.",
|
||||||
|
type: 'email',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'name@example.com',
|
||||||
|
conditional: {
|
||||||
|
showIf: { questionId: 'q17', condition: 'skipped' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'q19',
|
||||||
|
section: 5,
|
||||||
|
sectionTitle: 'Beta Program',
|
||||||
|
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: '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: 5,
|
||||||
|
title: 'Beta Program',
|
||||||
|
description:
|
||||||
|
"We're planning a closed beta before the full launch. If you'd like to be considered for early access, leave your email address below and we'll reach out when the beta is ready. You will also receive product updates as we progress towards a full release. If you only want updates, you can just signup to those on the next screen.",
|
||||||
|
questionIds: ['q17', 'q18', 'q19'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { questions } from './survey-definition';
|
||||||
|
import type { SurveyAnswer, SurveyQuestion } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: determines if a question should be visible given current answers.
|
||||||
|
* Q18 shows only when Q17 is skipped (has no answer).
|
||||||
|
*/
|
||||||
|
export function shouldShowQuestion(
|
||||||
|
q: SurveyQuestion,
|
||||||
|
answers: Record<string, SurveyAnswer>,
|
||||||
|
): boolean {
|
||||||
|
if (!q.conditional) return true;
|
||||||
|
if (q.conditional.showIf.condition === 'skipped') {
|
||||||
|
return !(q.conditional.showIf.questionId in answers);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: finds the next visible question index after currentIndex.
|
||||||
|
* Returns questions.length if no more visible questions (signals completion).
|
||||||
|
*/
|
||||||
|
export function findNextVisibleIndex(
|
||||||
|
currentIndex: number,
|
||||||
|
qs: SurveyQuestion[],
|
||||||
|
answers: Record<string, SurveyAnswer>,
|
||||||
|
): number {
|
||||||
|
let next = currentIndex + 1;
|
||||||
|
while (next < qs.length && !shouldShowQuestion(qs[next], answers)) {
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: finds the previous visible question index before currentIndex.
|
||||||
|
* Returns 0 if already at the beginning.
|
||||||
|
*/
|
||||||
|
export function findPrevVisibleIndex(
|
||||||
|
currentIndex: number,
|
||||||
|
qs: SurveyQuestion[],
|
||||||
|
answers: Record<string, SurveyAnswer>,
|
||||||
|
): number {
|
||||||
|
let prev = currentIndex - 1;
|
||||||
|
while (prev > 0 && !shouldShowQuestion(qs[prev], answers)) {
|
||||||
|
prev--;
|
||||||
|
}
|
||||||
|
return Math.max(prev, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: counts visible questions given current answers.
|
||||||
|
*/
|
||||||
|
export function getVisibleQuestionCount(
|
||||||
|
qs: SurveyQuestion[],
|
||||||
|
answers: Record<string, SurveyAnswer>,
|
||||||
|
): number {
|
||||||
|
return qs.filter((q) => shouldShowQuestion(q, answers)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive survey engine using Svelte 5 runes.
|
||||||
|
* Manages navigation, answers, conditional logic, and completion state.
|
||||||
|
*/
|
||||||
|
export function createSurveyEngine(
|
||||||
|
initialAnswers: Record<string, SurveyAnswer> = {},
|
||||||
|
startIndex = 0,
|
||||||
|
) {
|
||||||
|
let currentIndex = $state(startIndex);
|
||||||
|
let answers = $state<Record<string, SurveyAnswer>>({ ...initialAnswers });
|
||||||
|
let isComplete = $state(false);
|
||||||
|
|
||||||
|
const currentQuestion = $derived(questions[currentIndex] as SurveyQuestion | undefined);
|
||||||
|
const totalVisible = $derived(getVisibleQuestionCount(questions, answers));
|
||||||
|
const progress = $derived((Object.keys(answers).length / totalVisible) * 100);
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
const nextIdx = findNextVisibleIndex(currentIndex, questions, answers);
|
||||||
|
if (nextIdx >= questions.length) {
|
||||||
|
isComplete = true;
|
||||||
|
} else {
|
||||||
|
currentIndex = nextIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previous() {
|
||||||
|
currentIndex = findPrevVisibleIndex(currentIndex, questions, answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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,
|
||||||
|
setAnswer,
|
||||||
|
initialize,
|
||||||
|
shouldShowQuestion: (q: SurveyQuestion) => shouldShowQuestion(q, answers),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { questions } from './survey-definition';
|
||||||
|
import type { SurveyAnswer, SurveyQuestion } from './types';
|
||||||
|
import {
|
||||||
|
shouldShowQuestion,
|
||||||
|
findNextVisibleIndex,
|
||||||
|
findPrevVisibleIndex,
|
||||||
|
getVisibleQuestionCount,
|
||||||
|
} from './survey-engine.svelte';
|
||||||
|
|
||||||
|
// Helper: find index by question id
|
||||||
|
function indexOf(id: string): number {
|
||||||
|
return questions.findIndex((q) => q.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('shouldShowQuestion', () => {
|
||||||
|
it('returns true for regular questions without conditional', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = {};
|
||||||
|
expect(shouldShowQuestion(questions[0], answers)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for Q18 when Q17 has an answer', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = { q17: { value: 'test@example.com' } };
|
||||||
|
const q18 = questions[indexOf('q18')];
|
||||||
|
expect(shouldShowQuestion(q18, answers)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for Q18 when Q17 has no answer', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = {};
|
||||||
|
const q18 = questions[indexOf('q18')];
|
||||||
|
expect(shouldShowQuestion(q18, answers)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNextVisibleIndex', () => {
|
||||||
|
it('from Q16 skips Q18 when Q17 is answered, lands on Q19', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = { q17: { value: 'test@example.com' } };
|
||||||
|
const q17Index = indexOf('q17');
|
||||||
|
// From q17 (answered), next should skip q18 and land on q19
|
||||||
|
const result = findNextVisibleIndex(q17Index, questions, answers);
|
||||||
|
expect(result).toBe(indexOf('q19'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from Q16 goes to Q17 normally', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = {};
|
||||||
|
const q16Index = indexOf('q16');
|
||||||
|
const result = findNextVisibleIndex(q16Index, questions, answers);
|
||||||
|
expect(result).toBe(indexOf('q17'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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('skips hidden conditional questions going backward', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = { q17: { value: 'test@example.com' } };
|
||||||
|
const q19Index = indexOf('q19');
|
||||||
|
// From q19, going back should skip q18 (hidden) and land on q17
|
||||||
|
const result = findPrevVisibleIndex(q19Index, questions, answers);
|
||||||
|
expect(result).toBe(indexOf('q17'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('at index 0 returns 0', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = {};
|
||||||
|
const result = findPrevVisibleIndex(0, questions, answers);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVisibleQuestionCount', () => {
|
||||||
|
it('excludes hidden conditional questions', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = { q17: { value: 'test@example.com' } };
|
||||||
|
// Q18 should be hidden, so visible count = total - 1
|
||||||
|
expect(getVisibleQuestionCount(questions, answers)).toBe(questions.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes all questions when no conditional is triggered', () => {
|
||||||
|
const answers: Record<string, SurveyAnswer> = {};
|
||||||
|
expect(getVisibleQuestionCount(questions, answers)).toBe(questions.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type QuestionType = 'radio' | 'email' | 'textarea';
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveyQuestion {
|
||||||
|
id: string;
|
||||||
|
section: number;
|
||||||
|
sectionTitle: string;
|
||||||
|
text: string;
|
||||||
|
description?: string;
|
||||||
|
type: QuestionType;
|
||||||
|
options?: QuestionOption[];
|
||||||
|
hasOther?: boolean;
|
||||||
|
otherPrompt?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
required: boolean;
|
||||||
|
conditional?: {
|
||||||
|
showIf: { questionId: string; condition: 'skipped' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveySection {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
questionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurveyAnswer {
|
||||||
|
value: string;
|
||||||
|
otherText?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '$lib/app.css';
|
||||||
|
import { TooltipProvider } from '@immich/ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
{@render children?.()}
|
||||||
|
</TooltipProvider>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { createSurveyEngine } from '$lib/survey-engine.svelte';
|
||||||
|
import { fetchResume, fireAndForgetSave, postComplete, onSaveError } 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 error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onSaveError((message) => {
|
||||||
|
error = message;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const resume = await fetchResume();
|
||||||
|
if (resume.isComplete) {
|
||||||
|
alreadyCompleted = true;
|
||||||
|
} else if (resume.answers && resume.nextQuestionIndex !== undefined && resume.nextQuestionIndex > 0) {
|
||||||
|
engine.initialize(resume.answers, resume.nextQuestionIndex);
|
||||||
|
} else {
|
||||||
|
showWelcome = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Something went wrong. Please try again later.';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAnswer(questionId: string, value: string, otherText?: string) {
|
||||||
|
engine.setAnswer(questionId, value, otherText);
|
||||||
|
fireAndForgetSave({ questionId, value, otherText });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
try {
|
||||||
|
await postComplete();
|
||||||
|
surveyFinished = true;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to submit survey. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error && !showWelcome && !alreadyCompleted && !surveyFinished}
|
||||||
|
<div class="fixed top-2 left-1/2 z-[100] w-full max-w-lg -translate-x-1/2 px-4">
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-lg bg-red-600 px-4 py-3 text-sm text-white shadow-lg">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button onclick={() => (error = null)} class="shrink-0 font-semibold hover:underline">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<p class="text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error && !showWelcome && !engine.currentQuestion}
|
||||||
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg text-red-400">{error}</p>
|
||||||
|
<button onclick={() => location.reload()} class="mt-4 text-sm text-gray-400 hover:underline">Try again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if alreadyCompleted}
|
||||||
|
<AlreadyCompleted />
|
||||||
|
{:else if surveyFinished || engine.isComplete}
|
||||||
|
<ThankYouScreen />
|
||||||
|
{:else if showWelcome}
|
||||||
|
<WelcomeScreen onStart={() => (showWelcome = false)} />
|
||||||
|
{:else}
|
||||||
|
<SurveyShell {engine} onAnswer={handleAnswer} onComplete={handleComplete} />
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# FUTO Backups Survey
|
||||||
|
|
||||||
|
Survey URL: https://survey.futo.org/forms/futo-backups-survey-fy4lqx
|
||||||
|
|
||||||
|
## Section 1: Questions about your Immich server, your library and your internet speed
|
||||||
|
|
||||||
|
### Q1: Where is your Immich server located?
|
||||||
|
- North America - East (US East Coast, Eastern Canada)
|
||||||
|
- North America - Central (US Midwest, Central Canada)
|
||||||
|
- North America - West (US West Coast, Western Canada)
|
||||||
|
- South America
|
||||||
|
- Europe - West (UK, France, Netherlands, Nordics, etc.)
|
||||||
|
- Europe - East (Poland, Romania, Balkans, Baltics, etc.)
|
||||||
|
- Asia-Pacific - East (Japan, South Korea, Taiwan, etc.)
|
||||||
|
- Asia-Pacific - Southeast (Singapore, Australia, India, etc.)
|
||||||
|
- Middle East / Africa
|
||||||
|
- Other
|
||||||
|
|
||||||
|
### Q2: What is your server's download speed?
|
||||||
|
- Less than 10Mbps
|
||||||
|
- 10 - 25Mbps
|
||||||
|
- 26 - 50Mbps
|
||||||
|
- 51 - 100Mbps
|
||||||
|
- 101 - 500Mbps
|
||||||
|
- 501 - 1Gbps
|
||||||
|
- Over 1Gbps
|
||||||
|
- I'm not sure
|
||||||
|
|
||||||
|
### Q3: What is your server's upload speed?
|
||||||
|
- Less than 10Mbps
|
||||||
|
- 10 - 25Mbps
|
||||||
|
- 26 - 50Mbps
|
||||||
|
- 51 - 100Mbps
|
||||||
|
- 101 - 500Mbps
|
||||||
|
- 501 - 1Gbps
|
||||||
|
- Over 1Gbps
|
||||||
|
- I'm not sure
|
||||||
|
|
||||||
|
### Q4: What is the total size of your Immich library?
|
||||||
|
- Less than 100GB
|
||||||
|
- 100GB - 250GB
|
||||||
|
- 250GB - 500GB
|
||||||
|
- 500GB - 1TB
|
||||||
|
- 1 - 5TB
|
||||||
|
- 5 - 10TB
|
||||||
|
- 10 - 50TB
|
||||||
|
- 50TB+
|
||||||
|
|
||||||
|
### Q5: Roughly what percentage of your library by storage used is video?
|
||||||
|
> Example: If you have 600GB of photos & videos combined and 500GB of that is videos, select 80%.
|
||||||
|
|
||||||
|
- Almost none (less than 10%)
|
||||||
|
- A small portion (10 - 25%)
|
||||||
|
- A significant chunk (25 - 50%)
|
||||||
|
- Mostly video (50 - 75%)
|
||||||
|
- Almost entirely video (75%+)
|
||||||
|
- I'm not sure
|
||||||
|
|
||||||
|
### Q6: How would you describe your self-hosting experience?
|
||||||
|
- Novice - Immich is the first and only service I run and I followed a guide to do that
|
||||||
|
- Beginner - I have a few self-hosted services running but that is it
|
||||||
|
- Intermediate - I'm comfortable with Docker, basic networking, and Linux
|
||||||
|
- Advanced - I manage multiple services, custom configs, and infrastructure
|
||||||
|
- Expert - I work in IT/infrastructure professionally or have deep sysadmin experience
|
||||||
|
|
||||||
|
## Section 2: Your current backup solution
|
||||||
|
|
||||||
|
### Q7: How do you currently back up your Immich data?
|
||||||
|
- I don't currently have a backup
|
||||||
|
- RAID (but no offsite/separate copy)
|
||||||
|
- ZFS snapshots / replication
|
||||||
|
- Restic / Borg / Kopia or similar de-duplicating backup tool
|
||||||
|
- Syncthing / rsync to another machine
|
||||||
|
- Cloud sync (e.g. rclone to R2, S3, Backblaze B2, etc.)
|
||||||
|
- Manual / ad-hoc copies
|
||||||
|
- Other
|
||||||
|
|
||||||
|
**"Other" text entry:** Please enter how you currently back up your Immich instance
|
||||||
|
|
||||||
|
### Q8: How often do you run backups?
|
||||||
|
- Continuously / real-time
|
||||||
|
- Daily
|
||||||
|
- Weekly
|
||||||
|
- Monthly
|
||||||
|
- Less than monthly
|
||||||
|
- I don't have automated backups
|
||||||
|
|
||||||
|
### Q9: Roughly how much new data do you add to your Immich library in a typical month?
|
||||||
|
- Less than 1GB
|
||||||
|
- 1 - 10GB
|
||||||
|
- 10 - 50GB
|
||||||
|
- 50 - 100GB
|
||||||
|
- 100 - 500GB
|
||||||
|
- Over 500GB
|
||||||
|
|
||||||
|
## Section 3: Questions about FUTO Backups - and your interest in it
|
||||||
|
|
||||||
|
### Q10: How interested are you in a service like this?
|
||||||
|
- Very interested
|
||||||
|
- Interested
|
||||||
|
- Undecided / Need more details
|
||||||
|
- Not interested
|
||||||
|
|
||||||
|
### Q11: How much would you be willing to pay per TB per month for this service?
|
||||||
|
- $3.99/TB/Month
|
||||||
|
- $4.99/TB/Month
|
||||||
|
- $5.99/TB/Month
|
||||||
|
- $6.99/TB/Month
|
||||||
|
- $7.99/TB/Month
|
||||||
|
- $8.99/TB/Month
|
||||||
|
- $9.99/TB/Month
|
||||||
|
- I wouldn't pay for this
|
||||||
|
|
||||||
|
### Q12: How many copies of your backup would you want stored in the cloud?
|
||||||
|
> Each additional copy is stored in a separate location for extra redundancy and will cost extra
|
||||||
|
|
||||||
|
- 1 copy
|
||||||
|
- 2 copies
|
||||||
|
- 3 copies
|
||||||
|
- 4+ copies
|
||||||
|
- I'm not sure / It depends on the cost
|
||||||
|
|
||||||
|
## Section 4: Additional questions about how you host Immich
|
||||||
|
|
||||||
|
### Q13: What type of storage does your Immich server use for its media library?
|
||||||
|
- Directly attached storage
|
||||||
|
- Network attached storage
|
||||||
|
- Cloud/object storage (e.g. S3/MinIO)
|
||||||
|
- Other
|
||||||
|
|
||||||
|
**"Other" text entry:** Please enter what type of storage does your Immich server use for its media library
|
||||||
|
|
||||||
|
### Q14: What type of drives primarily make up your media storage?
|
||||||
|
- NVMe SSD
|
||||||
|
- SATA SSD
|
||||||
|
- HDD (spinning disk)
|
||||||
|
- Other
|
||||||
|
|
||||||
|
**"Other" text entry:** Please enter what type of drives primarily make up your media storage
|
||||||
|
|
||||||
|
### Q15: What is the underlying OS or Hypervisor running your Immich server?
|
||||||
|
- Bare metal Linux (Ubuntu, Debian, etc.)
|
||||||
|
- Proxmox VE
|
||||||
|
- TrueNAS / FreeNAS
|
||||||
|
- Unraid
|
||||||
|
- Windows / MacOS
|
||||||
|
- NixOS
|
||||||
|
- Umbrel
|
||||||
|
- ZimaOS
|
||||||
|
- HexOS
|
||||||
|
- Other
|
||||||
|
|
||||||
|
**"Other" text entry:** Please enter the underlying OS or Hypervisor running your Immich server
|
||||||
|
|
||||||
|
### Q16: How are you running Immich?
|
||||||
|
- Docker / Docker Compose
|
||||||
|
- Kubernetes
|
||||||
|
- LXC / System Container
|
||||||
|
- Nix
|
||||||
|
- Other
|
||||||
|
|
||||||
|
**"Other" text entry:** Please enter how you are running Immich
|
||||||
|
|
||||||
|
## Section 5: Private Beta Signup
|
||||||
|
|
||||||
|
### Q17: Beta Program
|
||||||
|
> We're planning a closed beta before the full launch. If you'd like to be considered for early access, enter your email below.
|
||||||
|
|
||||||
|
*Text entry:* Email address (name@example.com)
|
||||||
|
|
||||||
|
### Q18: Get Updates
|
||||||
|
> You didn't enroll in the beta program, if you would still like to receive product updates though, you can enter your email below.
|
||||||
|
|
||||||
|
*Text entry:* Email address (name@example.com)
|
||||||
|
|
||||||
|
### Q19: Is there anything else you'd like to share?
|
||||||
|
> Any thoughts, concerns, features you'd want, or questions about a managed backup service for Immich
|
||||||
|
|
||||||
|
*Rich text entry (0/5000 characters)*
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({ fallback: 'index.html' }),
|
||||||
|
alias: {
|
||||||
|
$common: '../../common',
|
||||||
|
'$common/*': '../../common/*',
|
||||||
|
'@immich/ui': resolve('./node_modules/@immich/ui/dist'),
|
||||||
|
'@mdi/js': resolve('./node_modules/@mdi/js'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"rootDir": "../../"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
fs: {
|
||||||
|
allow: ['../../common'],
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8787',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
expect: { requireAssertions: true },
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
terraform {
|
||||||
|
backend "pg" {}
|
||||||
|
required_version = "~> 1.7"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
cloudflare = {
|
||||||
|
source = "cloudflare/cloudflare"
|
||||||
|
version = "4.52.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
resource "cloudflare_d1_database" "survey" {
|
||||||
|
account_id = var.cloudflare_account_id
|
||||||
|
name = "futo-backups-survey${local.resource_suffix}"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "null_resource" "d1_migrations" {
|
||||||
|
triggers = {
|
||||||
|
migrations_hash = sha256(join("", [for f in fileset(var.migrations_dir, "*.sql") : filesha256("${var.migrations_dir}/${f}")]))
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioner "local-exec" {
|
||||||
|
command = <<-EOT
|
||||||
|
for f in $(ls ${var.migrations_dir}/*.sql | sort); do
|
||||||
|
echo "Applying migration: $f"
|
||||||
|
curl -sf -X POST \
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/${var.cloudflare_account_id}/d1/database/${cloudflare_d1_database.survey.id}/query" \
|
||||||
|
-H "Authorization: Bearer ${data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"sql\": $(cat "$f" | jq -Rs .)}" || echo "Migration $f may already be applied"
|
||||||
|
done
|
||||||
|
EOT
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [cloudflare_d1_database.survey]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
locals {
|
||||||
|
resource_stage = var.stage != "" ? "-${var.stage}" : ""
|
||||||
|
resource_env = "-${var.env}"
|
||||||
|
resource_suffix = "${local.resource_env}${local.resource_stage}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
provider "cloudflare" {
|
||||||
|
api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_account
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
data "terraform_remote_state" "api_keys_state" {
|
||||||
|
backend = "pg"
|
||||||
|
|
||||||
|
config = {
|
||||||
|
conn_str = var.tf_state_postgres_conn_str
|
||||||
|
schema_name = "prod_cloudflare_api_keys"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "terraform_remote_state" "cloudflare_account" {
|
||||||
|
backend = "pg"
|
||||||
|
|
||||||
|
config = {
|
||||||
|
conn_str = var.tf_state_postgres_conn_str
|
||||||
|
schema_name = "prod_cloudflare_account"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
terraform {
|
||||||
|
source = "."
|
||||||
|
|
||||||
|
extra_arguments custom_vars {
|
||||||
|
commands = get_terraform_commands_that_need_vars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
include {
|
||||||
|
path = find_in_parent_folders("state.hcl")
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
env = get_env("TF_VAR_env")
|
||||||
|
stage = get_env("TF_VAR_stage")
|
||||||
|
app_name = replace(get_env("TF_VAR_app_name"), "-", "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_state {
|
||||||
|
backend = "pg"
|
||||||
|
|
||||||
|
config = {
|
||||||
|
conn_str = get_env("TF_VAR_tf_state_postgres_conn_str")
|
||||||
|
schema_name = "cloudflare_workers_immich_app_${local.app_name}_${local.env}${local.stage}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
variable "tf_state_postgres_conn_str" {}
|
||||||
|
variable "stage" {}
|
||||||
|
variable "env" {}
|
||||||
|
variable "app_name" {}
|
||||||
|
variable "cloudflare_account_id" {}
|
||||||
|
variable "dist_dir" {}
|
||||||
|
variable "migrations_dir" {}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
+209
-5
@@ -423,7 +423,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@cloudflare/vitest-pool-workers':
|
'@cloudflare/vitest-pool-workers':
|
||||||
specifier: ^0.12.0
|
specifier: ^0.12.0
|
||||||
version: 0.12.21(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@3.2.4)
|
version: 0.12.21(@cloudflare/workers-types@4.20260317.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@3.2.4)
|
||||||
'@types/luxon':
|
'@types/luxon':
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.7.1
|
version: 3.7.1
|
||||||
@@ -435,7 +435,90 @@ importers:
|
|||||||
version: 3.2.4(@types/node@24.12.0)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.31.1)
|
version: 3.2.4(@types/node@24.12.0)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
wrangler:
|
wrangler:
|
||||||
specifier: ^4.18.0
|
specifier: ^4.18.0
|
||||||
version: 4.72.0
|
version: 4.72.0(@cloudflare/workers-types@4.20260317.1)
|
||||||
|
|
||||||
|
apps/futo-backups-survey.immich.app:
|
||||||
|
dependencies:
|
||||||
|
'@immich/ui':
|
||||||
|
specifier: ^0.65.0
|
||||||
|
version: 0.65.3(@sveltejs/kit@2.54.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.11)(tailwindcss@4.2.1)
|
||||||
|
'@mdi/js':
|
||||||
|
specifier: ^7.4.47
|
||||||
|
version: 7.4.47
|
||||||
|
devDependencies:
|
||||||
|
'@eslint/js':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.1(eslint@10.0.3(jiti@2.6.1))
|
||||||
|
'@sveltejs/adapter-static':
|
||||||
|
specifier: ^3.0.8
|
||||||
|
version: 3.0.10(@sveltejs/kit@2.54.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)))
|
||||||
|
'@sveltejs/kit':
|
||||||
|
specifier: ^2.43.2
|
||||||
|
version: 2.54.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.11)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
|
'@sveltejs/vite-plugin-svelte':
|
||||||
|
specifier: ^6.2.4
|
||||||
|
version: 6.2.4(svelte@5.53.11)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
|
'@tailwindcss/vite':
|
||||||
|
specifier: ^4.1.13
|
||||||
|
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.12.0
|
||||||
|
version: 24.12.0
|
||||||
|
eslint:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.3(jiti@2.6.1)
|
||||||
|
eslint-config-prettier:
|
||||||
|
specifier: ^10.1.8
|
||||||
|
version: 10.1.8(eslint@10.0.3(jiti@2.6.1))
|
||||||
|
eslint-plugin-svelte:
|
||||||
|
specifier: ^3.12.4
|
||||||
|
version: 3.15.2(eslint@10.0.3(jiti@2.6.1))(svelte@5.53.11)
|
||||||
|
globals:
|
||||||
|
specifier: ^17.0.0
|
||||||
|
version: 17.4.0
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.6.2
|
||||||
|
version: 3.8.1
|
||||||
|
prettier-plugin-svelte:
|
||||||
|
specifier: ^3.4.0
|
||||||
|
version: 3.5.1(prettier@3.8.1)(svelte@5.53.11)
|
||||||
|
prettier-plugin-tailwindcss:
|
||||||
|
specifier: ^0.7.2
|
||||||
|
version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.53.11))(prettier@3.8.1)
|
||||||
|
svelte:
|
||||||
|
specifier: ^5.39.5
|
||||||
|
version: 5.53.11
|
||||||
|
svelte-check:
|
||||||
|
specifier: ^4.3.2
|
||||||
|
version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3)
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.1.13
|
||||||
|
version: 4.2.1
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.2
|
||||||
|
version: 5.9.3
|
||||||
|
typescript-eslint:
|
||||||
|
specifier: ^8.44.1
|
||||||
|
version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
|
||||||
|
vite:
|
||||||
|
specifier: ^7.1.7
|
||||||
|
version: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
|
vitest:
|
||||||
|
specifier: ^4.0.12
|
||||||
|
version: 4.1.0(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))
|
||||||
|
|
||||||
|
apps/futo-backups-survey.immich.app/backend:
|
||||||
|
dependencies:
|
||||||
|
itty-router:
|
||||||
|
specifier: ^5.0.18
|
||||||
|
version: 5.0.23
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.2
|
||||||
|
version: 5.9.3
|
||||||
|
wrangler:
|
||||||
|
specifier: ^4.75.0
|
||||||
|
version: 4.75.0(@cloudflare/workers-types@4.20260317.1)
|
||||||
|
|
||||||
apps/get.immich.app:
|
apps/get.immich.app:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -768,30 +851,63 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260317.1':
|
||||||
|
resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
||||||
resolution: {integrity: sha512-h/Vl3XrYYPI6yFDE27XO1QPq/1G1lKIM8tzZGIWYpntK3IN5XtH3Ee/sLaegpJ49aIJoqhF2mVAZ6Yw+Vk2gJw==}
|
resolution: {integrity: sha512-h/Vl3XrYYPI6yFDE27XO1QPq/1G1lKIM8tzZGIWYpntK3IN5XtH3Ee/sLaegpJ49aIJoqhF2mVAZ6Yw+Vk2gJw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260317.1':
|
||||||
|
resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@cloudflare/workerd-linux-64@1.20260310.1':
|
'@cloudflare/workerd-linux-64@1.20260310.1':
|
||||||
resolution: {integrity: sha512-XzQ0GZ8G5P4d74bQYOIP2Su4CLdNPpYidrInaSOuSxMw+HamsHaFrjVsrV2mPy/yk2hi6SY2yMbgKFK9YjA7vw==}
|
resolution: {integrity: sha512-XzQ0GZ8G5P4d74bQYOIP2Su4CLdNPpYidrInaSOuSxMw+HamsHaFrjVsrV2mPy/yk2hi6SY2yMbgKFK9YjA7vw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260317.1':
|
||||||
|
resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
||||||
resolution: {integrity: sha512-sxv4CxnN4ZR0uQGTFVGa0V4KTqwdej/czpIc5tYS86G8FQQoGIBiAIs2VvU7b8EROPcandxYHDBPTb+D9HIMPw==}
|
resolution: {integrity: sha512-sxv4CxnN4ZR0uQGTFVGa0V4KTqwdej/czpIc5tYS86G8FQQoGIBiAIs2VvU7b8EROPcandxYHDBPTb+D9HIMPw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260317.1':
|
||||||
|
resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@cloudflare/workerd-windows-64@1.20260310.1':
|
'@cloudflare/workerd-windows-64@1.20260310.1':
|
||||||
resolution: {integrity: sha512-+1ZTViWKJypLfgH/luAHCqkent0DEBjAjvO40iAhOMHRLYP/SPphLvr4Jpi6lb+sIocS8Q1QZL4uM5Etg1Wskg==}
|
resolution: {integrity: sha512-+1ZTViWKJypLfgH/luAHCqkent0DEBjAjvO40iAhOMHRLYP/SPphLvr4Jpi6lb+sIocS8Q1QZL4uM5Etg1Wskg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260317.1':
|
||||||
|
resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260317.1':
|
||||||
|
resolution: {integrity: sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==}
|
||||||
|
|
||||||
'@cspotcode/source-map-support@0.8.1':
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2242,6 +2358,11 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
miniflare@4.20260317.0:
|
||||||
|
resolution: {integrity: sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -2650,6 +2771,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
|
resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
|
undici@7.24.4:
|
||||||
|
resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
unenv@2.0.0-rc.24:
|
unenv@2.0.0-rc.24:
|
||||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||||
|
|
||||||
@@ -2808,6 +2933,11 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
workerd@1.20260317.1:
|
||||||
|
resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
wrangler@4.72.0:
|
wrangler@4.72.0:
|
||||||
resolution: {integrity: sha512-bKkb8150JGzJZJWiNB2nu/33smVfawmfYiecA6rW4XH7xS23/jqMbgpdelM34W/7a1IhR66qeQGVqTRXROtAZg==}
|
resolution: {integrity: sha512-bKkb8150JGzJZJWiNB2nu/33smVfawmfYiecA6rW4XH7xS23/jqMbgpdelM34W/7a1IhR66qeQGVqTRXROtAZg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
@@ -2818,6 +2948,16 @@ packages:
|
|||||||
'@cloudflare/workers-types':
|
'@cloudflare/workers-types':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
wrangler@4.75.0:
|
||||||
|
resolution: {integrity: sha512-Efk1tcnm4eduBYpH1sSjMYydXMnIFPns/qABI3+fsbDrUk5GksNYX8nYGVP4sFygvGPO7kJc36YJKB5ooA7JAg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@cloudflare/workers-types': ^4.20260317.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@cloudflare/workers-types':
|
||||||
|
optional: true
|
||||||
|
|
||||||
ws@8.18.0:
|
ws@8.18.0:
|
||||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -2887,7 +3027,13 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
workerd: 1.20260310.1
|
workerd: 1.20260310.1
|
||||||
|
|
||||||
'@cloudflare/vitest-pool-workers@0.12.21(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@3.2.4)':
|
'@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)':
|
||||||
|
dependencies:
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
optionalDependencies:
|
||||||
|
workerd: 1.20260317.1
|
||||||
|
|
||||||
|
'@cloudflare/vitest-pool-workers@0.12.21(@cloudflare/workers-types@4.20260317.1)(@vitest/runner@4.1.0)(@vitest/snapshot@4.1.0)(vitest@3.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/runner': 4.1.0
|
'@vitest/runner': 4.1.0
|
||||||
'@vitest/snapshot': 4.1.0
|
'@vitest/snapshot': 4.1.0
|
||||||
@@ -2895,7 +3041,7 @@ snapshots:
|
|||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
miniflare: 4.20260310.0
|
miniflare: 4.20260310.0
|
||||||
vitest: 3.2.4(@types/node@24.12.0)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.31.1)
|
vitest: 3.2.4(@types/node@24.12.0)(@vitest/browser@3.2.4)(jiti@2.6.1)(lightningcss@1.31.1)
|
||||||
wrangler: 4.72.0
|
wrangler: 4.72.0(@cloudflare/workers-types@4.20260317.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
- bufferutil
|
- bufferutil
|
||||||
@@ -2904,18 +3050,36 @@ snapshots:
|
|||||||
'@cloudflare/workerd-darwin-64@1.20260310.1':
|
'@cloudflare/workerd-darwin-64@1.20260310.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-64@1.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-darwin-arm64@1.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@cloudflare/workerd-linux-64@1.20260310.1':
|
'@cloudflare/workerd-linux-64@1.20260310.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-64@1.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-linux-arm64@1.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@cloudflare/workerd-windows-64@1.20260310.1':
|
'@cloudflare/workerd-windows-64@1.20260310.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workerd-windows-64@1.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workers-types@4.20260317.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@cspotcode/source-map-support@0.8.1':
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@jridgewell/trace-mapping': 0.3.9
|
||||||
@@ -4189,6 +4353,18 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
miniflare@4.20260317.0:
|
||||||
|
dependencies:
|
||||||
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
|
sharp: 0.34.5
|
||||||
|
undici: 7.24.4
|
||||||
|
workerd: 1.20260317.1
|
||||||
|
ws: 8.18.0
|
||||||
|
youch: 4.1.0-beta.10
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.4
|
brace-expansion: 5.0.4
|
||||||
@@ -4554,6 +4730,8 @@ snapshots:
|
|||||||
|
|
||||||
undici@7.18.2: {}
|
undici@7.18.2: {}
|
||||||
|
|
||||||
|
undici@7.24.4: {}
|
||||||
|
|
||||||
unenv@2.0.0-rc.24:
|
unenv@2.0.0-rc.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
@@ -4707,7 +4885,15 @@ snapshots:
|
|||||||
'@cloudflare/workerd-linux-arm64': 1.20260310.1
|
'@cloudflare/workerd-linux-arm64': 1.20260310.1
|
||||||
'@cloudflare/workerd-windows-64': 1.20260310.1
|
'@cloudflare/workerd-windows-64': 1.20260310.1
|
||||||
|
|
||||||
wrangler@4.72.0:
|
workerd@1.20260317.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workerd-darwin-64': 1.20260317.1
|
||||||
|
'@cloudflare/workerd-darwin-arm64': 1.20260317.1
|
||||||
|
'@cloudflare/workerd-linux-64': 1.20260317.1
|
||||||
|
'@cloudflare/workerd-linux-arm64': 1.20260317.1
|
||||||
|
'@cloudflare/workerd-windows-64': 1.20260317.1
|
||||||
|
|
||||||
|
wrangler@4.72.0(@cloudflare/workers-types@4.20260317.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cloudflare/kv-asset-handler': 0.4.2
|
'@cloudflare/kv-asset-handler': 0.4.2
|
||||||
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)
|
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)
|
||||||
@@ -4718,6 +4904,24 @@ snapshots:
|
|||||||
unenv: 2.0.0-rc.24
|
unenv: 2.0.0-rc.24
|
||||||
workerd: 1.20260310.1
|
workerd: 1.20260310.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260317.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
wrangler@4.75.0(@cloudflare/workers-types@4.20260317.1):
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/kv-asset-handler': 0.4.2
|
||||||
|
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)
|
||||||
|
blake3-wasm: 2.1.5
|
||||||
|
esbuild: 0.27.3
|
||||||
|
miniflare: 4.20260317.0
|
||||||
|
path-to-regexp: 6.3.0
|
||||||
|
unenv: 2.0.0-rc.24
|
||||||
|
workerd: 1.20260317.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20260317.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
packages:
|
packages:
|
||||||
- apps/*
|
- apps/*
|
||||||
- apps/datasets.immich.app/backend
|
- apps/datasets.immich.app/backend
|
||||||
|
- apps/futo-backups-survey.immich.app/backend
|
||||||
|
|
||||||
dedupePeerDependents: true
|
dedupePeerDependents: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user