First implementation with progressive enhancement form
This commit is contained in:
@@ -8,3 +8,4 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
/static/tinymce
|
||||
+5
-2
@@ -3,8 +3,11 @@
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"printWidth": 1000,
|
||||
"bracketSameLine": true,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
"overrides": [
|
||||
{ "files": "*.svelte", "options": { "parser": "svelte" } }
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -16,6 +16,18 @@
|
||||
"name": "Firefox Attach",
|
||||
"type": "firefox",
|
||||
"request": "attach"
|
||||
},
|
||||
{
|
||||
"name": "Server Launch",
|
||||
"command": "npx vite dev",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"name": "Server Launch (vavite)",
|
||||
"command": "npx vavite-loader vite dev",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
Generated
+4126
-3899
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -15,9 +15,12 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/kit": "^1.5.0",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^3.1.0",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.26.0",
|
||||
@@ -33,5 +36,15 @@
|
||||
"vite": "^4.3.0",
|
||||
"vitest": "^0.25.3"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tinymce/tinymce-svelte": "^1.0.1",
|
||||
"@vavite/node-loader": "^1.8.1",
|
||||
"font-color-contrast": "^11.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.11.2",
|
||||
"rollup-plugin-copy": "^3.4.0",
|
||||
"svelte-awesome-color-picker": "^2.4.4",
|
||||
"tinymce": "^6.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
// Metrics database (in memory for simplicity)
|
||||
// Keyed by request ID: Stores the test ID, session ID, and duration of each request
|
||||
export const metricsDb = new Map<string, {
|
||||
testId: string,
|
||||
sessionId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
}>()
|
||||
@@ -0,0 +1,41 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export type Test = {
|
||||
id: string
|
||||
enabled: boolean
|
||||
chance: number
|
||||
title: string
|
||||
body: string
|
||||
color: string
|
||||
}
|
||||
|
||||
// Example data
|
||||
export const tests = writable<Test[]>([
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
enabled: false,
|
||||
chance: 0.5,
|
||||
title: 'Test 1',
|
||||
body: 'This is the body of test 1',
|
||||
color: 'red'
|
||||
},
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
enabled: false,
|
||||
chance: 0.5,
|
||||
title: 'Test 2',
|
||||
body: 'This is the body of test 2',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
enabled: false,
|
||||
chance: 0.5,
|
||||
title: 'Test 3',
|
||||
body: 'This is the body of test 3',
|
||||
color: 'green'
|
||||
},
|
||||
])
|
||||
|
||||
// Maps session ID to test ID to keep track of which test each user is in
|
||||
export const userTests = new Map<string, string>()
|
||||
@@ -2,4 +2,32 @@
|
||||
import '../app.css'
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
<html lang="en" data-theme="retro">
|
||||
<div class="navbar bg-base-100">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn-ghost btn lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu rounded-box menu-sm mt-3 w-52 bg-base-100 p-2 shadow">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/dash">Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a class="btn-ghost btn text-xl normal-case" href="/">Relevant A/B testing</a>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/dash">Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<a class="btn-primary btn min-w-0" href="/logout">Clear session cookie</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<div class="block h-32"></div>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { metricsDb } from '$lib/server/metrics';
|
||||
import { tests as testsStore, type Test, userTests } from '$lib/server/tests'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
|
||||
export async function load({ cookies, params }) {
|
||||
// Set a random session ID cookie if there is none, so we can uniquely
|
||||
// identify the user and not randomly change their experience on every page load.
|
||||
if (!cookies.get('sessionId')) {
|
||||
cookies.set('sessionId', Math.random().toString(36).slice(2), { sameSite: 'strict' });
|
||||
}
|
||||
|
||||
// Pick a test based on chance
|
||||
let test: Test
|
||||
const tests = get(testsStore).filter(test => test.enabled)
|
||||
if (tests.length === 0) {
|
||||
throw new Error('No tests enabled')
|
||||
}
|
||||
// If user already has a test assigned, use that
|
||||
test = tests.find(test => test.id === userTests.get(cookies.get('sessionId')!)!)!
|
||||
if (test === undefined || !test.enabled) { // If the assigned test was disabled after the fact, pick a new one anyways
|
||||
const sum = tests.reduce((acc, test) => acc + test.chance, 0)
|
||||
const rand = Math.random() * sum
|
||||
let cumulative = 0
|
||||
test = tests.find(test => {
|
||||
cumulative += test.chance
|
||||
return rand <= cumulative
|
||||
})!
|
||||
userTests.set(cookies.get('sessionId')!, test.id)
|
||||
}
|
||||
|
||||
// Random request ID for metrics logging
|
||||
const requestId = Math.random().toString(36).slice(2)
|
||||
metricsDb.set(requestId, {
|
||||
testId: test.id,
|
||||
sessionId: cookies.get('sessionId')!,
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
})
|
||||
|
||||
return {
|
||||
test,
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
+30
-2
@@ -1,2 +1,30 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import fontColorContrast from 'font-color-contrast'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
export let data
|
||||
|
||||
// Auto-select a font color that contrasts with the background color ✨
|
||||
let fontColor: string
|
||||
$: data.test && (fontColor = fontColorContrast(data.test.color))
|
||||
|
||||
// Client-side metrics (visit duration etc)
|
||||
// Sending metrics every second since I'm not sure if onbeforeunload is reliable?
|
||||
if (browser) {
|
||||
const interval = setInterval(() => {
|
||||
fetch(`/dash/metrics/${data.requestId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: window.location.pathname,
|
||||
}),
|
||||
})
|
||||
}, 1000)
|
||||
onDestroy(() => clearInterval(interval))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto mt-16 w-full max-w-screen-lg rounded-lg p-4 shadow-lg" style="background-color: {data.test?.color}; color: {fontColor}">
|
||||
<h1 class="text-4xl font-bold">{@html data.test?.title}</h1>
|
||||
<p class="text-lg">{@html data.test?.body}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { metricsDb } from '$lib/server/metrics.js'
|
||||
import { tests as testsStore, type Test } from '$lib/server/tests'
|
||||
import { get } from 'svelte/store'
|
||||
import _ from 'lodash'
|
||||
import qs from 'qs'
|
||||
import { fail } from '@sveltejs/kit'
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
// Concatenate tests with analytics
|
||||
const tests = get(testsStore).map(test => {
|
||||
const testMetrics = [ ...metricsDb.values() ].filter(metrics => metrics.testId === test.id)
|
||||
const views = testMetrics.length
|
||||
const averageDuration = testMetrics.reduce((acc, metrics) => acc + (metrics.end.getTime() - metrics.start.getTime()), 0) / views
|
||||
return {
|
||||
...test,
|
||||
analytics: {
|
||||
views,
|
||||
duration: averageDuration / 1000
|
||||
}
|
||||
}
|
||||
}) as (Test & { analytics?: { views: number, duration: number } })[]
|
||||
|
||||
return { tests }
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const form = await request.formData()
|
||||
// HACK: Sveltekit's progressive enhancement tends to send the form as
|
||||
// multipart, so we convert it to a query string so we can parse it using qs
|
||||
// and save time on trying to group the fields manually.
|
||||
const formAsQueryString = Array.from(form.entries()).map(([key, value]) => `${key}=${value}`).join('&')
|
||||
// Parse form body with qs
|
||||
const parsed = qs.parse(formAsQueryString) as { test: { id: string, enabled: string[], chance: string, title: string, body: string, color: string }[] }
|
||||
|
||||
// Validate
|
||||
for (const parsedTest of parsed.test) {
|
||||
if (parsedTest.title.length === 0) {
|
||||
return fail(400, { message: 'Title cannot be empty' })
|
||||
} else if (parsedTest.body.length === 0) {
|
||||
return fail(400, { message: 'Body cannot be empty' })
|
||||
} else if (parsedTest.color.length === 0) {
|
||||
return fail(400, { message: 'Color is required' })
|
||||
} else if (parsedTest.chance.length === 0) {
|
||||
return fail(400, { message: 'Chance is required' })
|
||||
} else if (isNaN(parseFloat(parsedTest.chance.toString()))) {
|
||||
return fail(400, { message: 'Chance must be a number' })
|
||||
}
|
||||
}
|
||||
if (parsed.test.every(parsedTest => _.first(parsedTest.enabled) !== 'on')) {
|
||||
return fail(400, { message: 'At least one test must be enabled' })
|
||||
}
|
||||
|
||||
// Update tests in store
|
||||
testsStore.update((tests) => {
|
||||
// Delete missing IDs
|
||||
tests = tests.filter(test => parsed.test.some(parsedTest => parsedTest.id === test.id))
|
||||
// Update or create
|
||||
for (const parsedTest of parsed.test) {
|
||||
const test = tests.find(test => test.id === parsedTest.id)
|
||||
if (test) {
|
||||
test.enabled = _.first(parsedTest.enabled) === 'on'
|
||||
test.chance = parseFloat(parsedTest.chance.toString())
|
||||
test.title = parsedTest.title.toString()
|
||||
test.body = parsedTest.body.toString()
|
||||
test.color = parsedTest.color.toString()
|
||||
} else {
|
||||
tests.push({
|
||||
id: Math.random().toString(36).slice(2),
|
||||
enabled: _.first(parsedTest.enabled) === 'on',
|
||||
chance: parseFloat(parsedTest.chance.toString()),
|
||||
title: parsedTest.title.toString(),
|
||||
body: parsedTest.body.toString(),
|
||||
color: parsedTest.color.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return tests
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
type StructuredFormData =
|
||||
| string
|
||||
| boolean
|
||||
| number
|
||||
| File
|
||||
| StructuredFormData[];
|
||||
|
||||
function formBody(body: FormData) {
|
||||
return [...body.entries()].reduce((data, [k, v]) => {
|
||||
let value: StructuredFormData = v;
|
||||
if (v === "true") value = true;
|
||||
if (v === "false") value = false;
|
||||
if (!isNaN(Number(v))) value = Number(v);
|
||||
|
||||
// For grouped fields like multi-selects and checkboxes, we need to
|
||||
// store the values in an array.
|
||||
if (k in data) {
|
||||
const val = data[k];
|
||||
value = Array.isArray(val) ? [...val, value] : [val, value];
|
||||
}
|
||||
data[k] = value;
|
||||
return data;
|
||||
}, {} as Record<string, StructuredFormData>);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms'
|
||||
import { page } from '$app/stores'
|
||||
import type { Test } from '$lib/server/tests'
|
||||
import { tick } from 'svelte'
|
||||
import AB from './AB.svelte'
|
||||
|
||||
export let data
|
||||
export let form
|
||||
|
||||
function addNew() {
|
||||
data.tests = [
|
||||
...data.tests,
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
enabled: true,
|
||||
chance: 0.5,
|
||||
title: 'New Test',
|
||||
body: '',
|
||||
color: '#ffffff',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function deleteTest(index: number) {
|
||||
data.tests.splice(index, 1)
|
||||
data.tests = data.tests
|
||||
}
|
||||
|
||||
function onChanceChanged(test: Test) {
|
||||
// Adjust other enabled chances to make them add up to 1
|
||||
const tests = data.tests.filter((t) => t.enabled)
|
||||
const otherTests = tests.filter((t) => t.id !== test.id)
|
||||
const totalChance = tests.reduce((acc, t) => acc + t.chance, 0)
|
||||
const diff = 1 - totalChance
|
||||
const diffPerTest = diff / otherTests.length
|
||||
otherTests.forEach((t) => (t.chance = Math.round(Math.max(0, Math.min(t.chance + diffPerTest, 1) * 100)) / 100))
|
||||
}
|
||||
|
||||
function preventSubmit(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto mt-16 w-full max-w-screen-lg">
|
||||
<h1 class="text-4xl font-bold">Dashboard</h1>
|
||||
<p class="mt-2">Manage your A/B tests here.</p>
|
||||
|
||||
<form method="POST" on:keydown={preventSubmit} use:enhance>
|
||||
<div class="mt-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each data.tests as test, index (test.id)}
|
||||
<AB i={index} bind:data={test} on:delete={() => deleteTest(index)} on:chanceChanged={() => onChanceChanged(test)} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="alert alert-success mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Changes saved successfully.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $page.status === 400 && form?.message}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>{form?.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button class="btn-bordered btn-primary btn">Save</button>
|
||||
<button class="btn-bordered btn-secondary btn ml-2" on:click|preventDefault={addNew}>Add New</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import ColorPicker from 'svelte-awesome-color-picker'
|
||||
import Editor from '@tinymce/tinymce-svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import type { Test } from '$lib/server/tests'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let i: number
|
||||
export let data: Test & { analytics?: { views: number; duration: number } }
|
||||
|
||||
function deleteSelf(this: HTMLButtonElement) {
|
||||
if (this.innerHTML !== 'Delete?') {
|
||||
this.innerHTML = 'Delete?'
|
||||
} else {
|
||||
this.disabled = true
|
||||
dispatch('delete')
|
||||
}
|
||||
}
|
||||
|
||||
function chanceChanged(this: HTMLInputElement) {
|
||||
if (data.enabled) {
|
||||
dispatch('chanceChanged', data.chance)
|
||||
}
|
||||
}
|
||||
|
||||
let chancePercent: number
|
||||
$: chancePercent = Math.round(data.chance * 100)
|
||||
// On enable, send a chanceChanged event so that the parent can adjust other enabled chances to make them add up to 1
|
||||
$: if (data.enabled) {
|
||||
dispatch('chanceChanged', data.chance)
|
||||
}
|
||||
|
||||
const enabled = data.enabled
|
||||
</script>
|
||||
|
||||
<details class="collapse collapse-arrow rounded-lg" open={enabled} >
|
||||
<summary class="collapse-title text-xl font-medium leading-tight bg-base-300 min-h-min">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="test[{i}][enabled]" class="toggle toggle-accent" bind:checked={data.enabled} />
|
||||
<input type="hidden" name="test[{i}][enabled]" value='off' />
|
||||
<span style="color: {data.color}">{data.title}</span>
|
||||
<!-- <span>{data.id}</span> -->
|
||||
<input type="range" name="test[{i}][chance]" class="range max-w-xs ml-20" min="0" max="1" step="0.01" bind:value={data.chance} on:input={() => chancePercent = Math.round(data.chance * 100)} on:input={chanceChanged} />
|
||||
<input type="text" class="input input-ghost w-16" bind:value={chancePercent} on:input={() => data.chance = chancePercent / 100} on:input={chanceChanged} />
|
||||
<!-- <span>{Math.round(data.chance * 100)}%</span> -->
|
||||
<!-- <span>{data.chance}</span> -->
|
||||
</div>
|
||||
</summary>
|
||||
<div class="collapse-content flex w-full flex-col flex-wrap gap-4 bg-base-200">
|
||||
<input type="hidden" name="test[{i}][id]" value={data.id} />
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Background Colour</span>
|
||||
</label>
|
||||
<input type="hidden" name="test[{i}][color]" value={data.color} />
|
||||
<ColorPicker bind:hex={data.color} isOpen={true} isPopup={false} isInput={false} />
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Title
|
||||
{#if data.title.length == 0}
|
||||
<span class="text-red-500"> (required)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<input type="text" name="test[{i}][title]" bind:value={data.title} class="input-bordered input" class:input-error={data.title.length === 0} />
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Body
|
||||
{#if data.body.length == 0}
|
||||
<span class="text-red-500"> (required)</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<input type="hidden" name="test[{i}][body]" value={data.body} />
|
||||
<Editor scriptSrc="tinymce/tinymce.min.js" conf={{}} bind:value={data.body} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
class="btn-error btn-sm btn"
|
||||
on:click|preventDefault={deleteSelf}
|
||||
on:blur={function () {
|
||||
this.innerHTML = 'Delete'
|
||||
}}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.analytics}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Analytics</h1>
|
||||
<p>Page views: {data.analytics.views}</p>
|
||||
<p>Average duration: {isNaN(data.analytics.duration) ? '???' : Math.round(data.analytics.duration)} sec</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
:global(.color-picker .wrapper) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { metricsDb } from "$lib/server/metrics";
|
||||
import { json } from "@sveltejs/kit";
|
||||
|
||||
export async function POST({ params, cookies, request }) {
|
||||
// Update metrics page view end time
|
||||
const { requestId } = params
|
||||
const metrics = metricsDb.get(requestId)
|
||||
if (!metrics) {
|
||||
throw new Error('No metrics found for request ID')
|
||||
}
|
||||
metrics.end = new Date()
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { userTests } from '$lib/server/tests.js'
|
||||
|
||||
export async function GET({ cookies }) {
|
||||
userTests.delete(cookies.get('sessionId')!)
|
||||
cookies.delete('sessionId')
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: '/',
|
||||
},
|
||||
})
|
||||
}
|
||||
+4
-1
@@ -4,5 +4,8 @@ export default {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require('daisyui')],
|
||||
daisyui: {
|
||||
themes: ['light', 'dark', 'retro']
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import copy from 'rollup-plugin-copy'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { nodeLoaderPlugin } from '@vavite/node-loader/plugin'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
plugins: [
|
||||
// nodeLoaderPlugin(),
|
||||
sveltekit(),
|
||||
copy({ targets: [{ src: 'node_modules/tinymce/*', dest: 'static/tinymce' }] }),
|
||||
],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user