added readme and svelte

This commit is contained in:
2025-07-21 13:31:08 +02:00
commit f58e9ef5a2
28 changed files with 6024 additions and 0 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Replace with your DB credentials!
DATABASE_URL="libsql://db-name-user.turso.io"
DATABASE_AUTH_TOKEN=""
# A local DB can also be used in dev as well
# DATABASE_URL="file:local.db"

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# SQLite
*.db

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
/drizzle/

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
# TaptApp Data collection platform
## Overview
This is a small web app to collect data from users for a study. It used SvelteKit, Drizzle ORM (Turso), Tailwind, and Lucia for authentication.
The data collection involves users listening to audio files and simultaneously rating them using a slider. All slider movements will be saved to we have a time course of participants' ratings throughout the audio file.
The app should use shadcn svelte components for the UI where possible.
## App structure
The app will have an admin backend and a user frontend. The admin backend is used create invite links, while the user frontend is where participants will listen to audio files and provide their ratings.
### Admin backend
The admin backend should be password protected. The username and password are stored in the `.env` file. The admin backend allows you to:
- Create invite links for participants
- View existing invite links
- View participant ratings
### User frontend
The user frontend is where participants will interact with the app. It allows users to:
- Access using their invite link. The invite link will contain a unique token that identifies the participant.
- Listen to audio files
- Rate the audio files using a slider
- Leave and resume their session. This means that users should be able to see which audio files they have already rated and continue from where they left off.

14
drizzle.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.js',
dialect: 'turso',
dbCredentials: {
authToken: process.env.DATABASE_AUTH_TOKEN,
url: process.env.DATABASE_URL
},
verbose: true,
strict: true
});

27
eslint.config.js Normal file
View File

@@ -0,0 +1,27 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
{
files: ['**/*.svelte', '**/*.svelte.js'],
languageOptions: { parserOptions: { svelteConfig } }
}
];

13
jsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

5463
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "taptapp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22",
"drizzle-kit": "^0.30.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^7.0.4"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"drizzle-orm": "^0.40.0"
}
}

3
src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

25
src/hooks.server.js Normal file
View File

@@ -0,0 +1,25 @@
import * as auth from '$lib/server/auth';
const handleAuth = async ({ event, resolve }) => {
const sessionToken = event.cookies.get(auth.sessionCookieName);
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} else {
auth.deleteSessionTokenCookie(event);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};
export const handle = handleAuth;

1
src/lib/index.js Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

90
src/lib/server/auth.js Normal file
View File

@@ -0,0 +1,90 @@
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
}
/**
* @param {string} token
* @param {string} userId
*/
export async function createSession(token, userId) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
};
await db.insert(table.session).values(session);
return session;
}
/** @param {string} token */
export async function validateSessionToken(token) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username },
session: table.session
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
.update(table.session)
.set({ expiresAt: session.expiresAt })
.where(eq(table.session.id, session.id));
}
return { session, user };
}
/** @param {string} sessionId */
export async function invalidateSession(sessionId) {
await db.delete(table.session).where(eq(table.session.id, sessionId));
}
/**
* @param {import("@sveltejs/kit").RequestEvent} event
* @param {string} token
* @param {Date} expiresAt
*/
export function setSessionTokenCookie(event, token, expiresAt) {
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
}
/** @param {import("@sveltejs/kit").RequestEvent} event */
export function deleteSessionTokenCookie(event) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
}

View File

@@ -0,0 +1,15 @@
import { dev } from '$app/environment';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');
const client = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN
});
export const db = drizzle(client, { schema });

View File

@@ -0,0 +1,16 @@
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
age: integer('age'),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull()
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});

View File

@@ -0,0 +1,7 @@
<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}

2
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,2 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View File

@@ -0,0 +1 @@
<a href="/demo/lucia">lucia</a>

View File

@@ -0,0 +1,30 @@
import * as auth from '$lib/server/auth';
import { fail, redirect } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server';
export const load = async () => {
const user = requireLogin();
return { user };
};
export const actions = {
logout: async (event) => {
if (!event.locals.session) {
return fail(401);
}
await auth.invalidateSession(event.locals.session.id);
auth.deleteSessionTokenCookie(event);
return redirect(302, '/demo/lucia/login');
}
};
function requireLogin() {
const { locals } = getRequestEvent();
if (!locals.user) {
return redirect(302, '/demo/lucia/login');
}
return locals.user;
}

View File

@@ -0,0 +1,11 @@
<script>
import { enhance } from '$app/forms';
let { data } = $props();
</script>
<h1>Hi, {data.user.username}!</h1>
<p>Your user ID is {data.user.id}.</p>
<form method="post" action="?/logout" use:enhance>
<button>Sign out</button>
</form>

View File

@@ -0,0 +1,106 @@
import { hash, verify } from '@node-rs/argon2';
import { encodeBase32LowerCase } from '@oslojs/encoding';
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
export const load = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia');
}
return {};
};
export const actions = {
login: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
if (!validateUsername(username)) {
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
});
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' });
}
const results = await db.select().from(table.user).where(eq(table.user.username, username));
const existingUser = results.at(0);
if (!existingUser) {
return fail(400, { message: 'Incorrect username or password' });
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
if (!validPassword) {
return fail(400, { message: 'Incorrect username or password' });
}
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, existingUser.id);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
return redirect(302, '/demo/lucia');
},
register: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
if (!validateUsername(username)) {
return fail(400, { message: 'Invalid username' });
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password' });
}
const userId = generateUserId();
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
try {
await db.insert(table.user).values({ id: userId, username, passwordHash });
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} catch {
return fail(500, { message: 'An error has occurred' });
}
return redirect(302, '/demo/lucia');
}
};
function generateUserId() {
// ID with 120 bits of entropy, or about the same as UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(15));
const id = encodeBase32LowerCase(bytes);
return id;
}
function validateUsername(username) {
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
);
}
function validatePassword(password) {
return typeof password === 'string' && password.length >= 6 && password.length <= 255;
}

View File

@@ -0,0 +1,33 @@
<script>
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<h1>Login/Register</h1>
<form method="post" action="?/login" use:enhance>
<label>
Username
<input
name="username"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<label>
Password
<input
type="password"
name="password"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Login</button
>
<button
formaction="?/register"
class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Register</button
>
</form>
<p style="color: red">{form?.message ?? ''}</p>

1
static/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

13
svelte.config.js Normal file
View File

@@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

7
vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});