UX improvements #8

Merged
erman merged 8 commits from supabase into main 2025-06-24 19:15:46 +02:00
14 changed files with 364 additions and 113 deletions

21
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3"
"papaparse": "^5.5.3",
"quoted-printable": "^1.0.1"
},
"devDependencies": {
"@sveltejs/kit": "^2.16.0",
@@ -2691,6 +2692,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quoted-printable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz",
"integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==",
"license": "MIT",
"dependencies": {
"utf8": "^2.1.0"
},
"bin": {
"quoted-printable": "bin/quoted-printable"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3050,6 +3063,12 @@
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/utf8": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz",
"integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -33,6 +33,7 @@
"@supabase/supabase-js": "^2.50.0",
"@sveltejs/adapter-node": "^5.2.12",
"googleapis": "^150.0.1",
"papaparse": "^5.5.3"
"papaparse": "^5.5.3",
"quoted-printable": "^1.0.1"
}
}

View File

@@ -1,5 +1,6 @@
import { google } from 'googleapis';
import { env } from '$env/dynamic/private';
import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package
export const scopes = ['https://www.googleapis.com/auth/gmail.send'];
@@ -27,21 +28,73 @@ export async function exchangeCodeForTokens(code: string) {
export async function sendGmail(
refreshToken: string,
{ to, subject, text }: { to: string; subject: string; text: string }
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) {
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const gmail = google.gmail({ version: 'v1', auth: oauth });
const raw = Buffer
.from(
[`To: ${to}`,
'Content-Type: text/plain; charset="UTF-8"',
'Content-Transfer-Encoding: 7bit',
const message_html =
`<!DOCTYPE html>
<html lang="en">
<head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Lato&display=swap');
</style>
</head>
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;">
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of *insert software name*</p>
</div>
</div>
</body>
</html>`;
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
const htmlQP = quotedPrintable.encode(message_html);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'',
text].join('\n'))
.toString('base64url');
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
`--${boundary}--`,
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
await gmail.users.messages.send({
userId: 'me',

View File

@@ -11,6 +11,6 @@
</ul>
</nav>
<div class="container p-2 bg-white ">
<div class="container max-w-2xl mx-auto p-2 bg-white">
<slot />
</div>

View File

@@ -36,13 +36,13 @@ export const GET: RequestHandler = async ({ url }) => {
/* ───────────── POST ───────────── */
export const POST: RequestHandler = async ({ request }) => {
const { action, refreshToken, to, subject, text } = await request.json();
const { action, refreshToken, to, subject, text, qr_code } = await request.json();
/* send e-mail */
if (action === 'send') {
if (!refreshToken) return new Response('Missing token', { status: 401 });
try {
await sendGmail(refreshToken, { to, subject, text });
await sendGmail(refreshToken, { to, subject, text, qr_code });
return json({ ok: true });
} catch (err) {
return new Response((err as Error).message, { status: 500 });

View File

@@ -4,8 +4,6 @@ import Papa from 'papaparse';
import { fail } from '@sveltejs/kit';
export async function load({ locals }) {
console.log("▶️ fetching events…");
const { data: events, error } = await locals.supabase
.from('events')
.select('*')
@@ -23,8 +21,6 @@ export async function load({ locals }) {
export const actions = {
create: async (event) => {
const formData = await event.request.formData();
console.log(Array.from(formData.entries()));
console.log('create_event date', formData.get("date"), formData.get("name"), formData.get("description"));
let { data: new_event, error } = await event.locals.supabase.rpc("create_event",
{
"p_name": formData.get('name'),
@@ -51,8 +47,6 @@ export const actions = {
parsedRows.shift();
}
console.log('Parsed rows:', parsedRows);
// Map each row to an object with keys: name, surname, email
const participants = parsedRows.map((row: string[]) => ({
name: row[0],
@@ -60,8 +54,6 @@ export const actions = {
email: row[2]
}));
console.log('Mapped participants:', participants);
return {
participants,
}

View File

@@ -11,9 +11,9 @@
let participants = $state([]);
let subject = $state('');
let body = $state('');
let authorized = $state(false);
// Update events and participants from the form data
$effect( () => {
$effect(() => {
if (form && form.new_event) {
new_event = form.new_event;
}
@@ -31,36 +31,26 @@
StepOverview
];
// State variable for current step
let step = $state(0);
let step: number = $state(0);
let stepConditions = $derived([
authorized,
!!new_event?.name,
!!participants?.length,
!!subject && !!body
]);
// Helper to go to next/previous step (optional)
function nextStep() {
if (step < steps.length - 1) step += 1;
console.log(step);
}
function prevStep() {
if (step > 0) step -= 1;
console.log(step);
}
</script>
<!-- Render the current step component -->
{#if step == 0}
<StepConnectGoogle />
{:else if step == 1}
<StepCreateEvent events={data.events} {new_event} />
{:else if step == 2}
<StepUploadFiles {participants} />
{:else if step == 3}
<StepCraftEmail bind:subject bind:body />
{:else if step == 4}
<StepOverview {new_event} {participants} {subject} {body} />
{/if}
<div class="flex items-center justify-between mt-4">
<div class="flex items-center justify-between mb-4 mt-2">
<button
on:click={prevStep}
onclick={prevStep}
disabled={step === 0}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
@@ -70,10 +60,28 @@
Step {step + 1} of {steps.length}
</span>
<button
on:click={nextStep}
disabled={step === steps.length - 1}
onclick={nextStep}
disabled={step === steps.length - 1 || !stepConditions[step]}
class="min-w-[100px] py-2 px-4 bg-white border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Next
</button>
</div>
{#if step == 0}
<StepConnectGoogle bind:authorized />
{:else if step == 1}
<StepCreateEvent events={data.events} {new_event} />
{:else if step == 2}
<StepUploadFiles {participants} />
{:else if step == 3}
<StepCraftEmail bind:subject bind:body />
{:else if step == 4}
<StepOverview
{new_event}
{participants}
{subject}
{body}
{stepConditions}
/>
{/if}

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let to = '';
let subject = '';
let body = '';
let qrcode_b64 = '';
let loading = false;
let error = '';
let success = '';
let authorized = false;
let refreshToken = '';
async function validateToken(token: string): Promise<boolean> {
if (!token) return false;
const res = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'validate', refreshToken: token })
});
if (!res.ok) return false;
const data = await res.json();
return !!data.valid;
}
onMount(async () => {
refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
authorized = await validateToken(refreshToken);
});
const connect = () => goto('/private/api/gmail?action=auth');
async function sendTestEmail() {
error = '';
success = '';
loading = true;
try {
const r = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'send',
to,
subject,
text: body,
qr_code: qrcode_b64,
refreshToken
})
});
if (r.ok) {
success = 'Email sent!';
to = subject = body = qrcode_b64 = '';
} else {
error = await r.text();
}
} catch (e) {
error = e.message || 'Unknown error';
}
loading = false;
}
</script>
<div class="max-w-lg mx-auto bg-white border border-gray-300 rounded p-8 mt-8 shadow">
<h2 class="text-2xl font-semibold mb-6 text-center">Test Email Sender</h2>
{#if !authorized}
<div class="mb-4 flex items-center justify-between">
<p class="text-gray-700">Google not connected.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</div>
{:else}
<form on:submit|preventDefault={sendTestEmail} class="space-y-4">
<label class="block">
<span class="text-gray-700">To</span>
<input type="email" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={to} required />
</label>
<label class="block">
<span class="text-gray-700">Subject</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200" bind:value={subject} required />
</label>
<label class="block">
<span class="text-gray-700">Body</span>
<textarea class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 resize-none" rows="6" bind:value={body} required></textarea>
</label>
<label class="block">
<span class="text-gray-700">QR Code (base64, data:image/png;base64,...)</span>
<input type="text" class="mt-1 block w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-200 font-mono text-xs" bind:value={qrcode_b64} placeholder="Paste base64 image string here" required />
</label>
<button type="submit" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition" disabled={loading}>
{#if loading}
<svg class="animate-spin h-5 w-5 mr-2 inline-block text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Sending...
{:else}
Send Test Email
{/if}
</button>
</form>
{/if}
{#if error}
<div class="rounded border-l-4 border-red-500 bg-red-100 p-4 text-red-700 mt-4">{error}</div>
{/if}
{#if success}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">{success}</div>
{/if}
</div>

View File

@@ -2,8 +2,10 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
export let authorized = false;
let refreshToken = '';
let authorized = false;
let loading = true;
let to = '';
let subject = '';
@@ -22,30 +24,15 @@
}
onMount(async () => {
console.log("on mount");
refreshToken = localStorage.getItem('gmail_refresh_token') ?? '';
loading = true;
authorized = await validateToken(refreshToken);
loading = false;
});
/* ⇢ redirects straight to Google via server 302 */
const connect = () => goto('/private/api/gmail?action=auth');
async function sendEmail() {
const r = await fetch('/private/api/gmail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'send',
to,
subject,
text: body,
refreshToken
})
});
r.ok ? alert('Sent!') : alert(await r.text());
to = subject = body = '';
}
async function disconnect() {
if (!confirm('Disconnect Google account?')) return;
await fetch('/private/api/gmail', {
@@ -59,11 +46,30 @@
}
</script>
{#if !authorized}
<section class="space-y-4">
<p>You havent connected your Google account yet.</p>
<button class="btn" on:click={connect}>Connect Google</button>
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
{#if loading}
<div class="flex items-center space-x-2">
<svg class="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
<span>Checking Google connection...</span>
</div>
{:else}
{#if !authorized}
<section class="flex items-center justify-between w-full">
<p class="mr-4">You havent connected your Google account yet.</p>
<button class="btn bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded ml-auto" on:click={connect}>
Connect Google
</button>
</section>
{:else}
Your connection is good, proceed to next step
{/if}
{:else}
<div class="flex items-center space-x-2 text-green-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Your connection to Google is good, proceed to next step</span>
</div>
{/if}
{/if}
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
export let subject = '';
export let body = '';
export let subject: string;
export let body: string;
</script>
<form class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">

View File

@@ -2,11 +2,20 @@
import { enhance } from '$app/forms';
let { events, new_event } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script>
<form method="POST" action="?/create" use:enhance class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<form method="POST" action="?/create" use:enhance={handleEnhance} class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Create Event</h2>
<label class="flex flex-col text-gray-700">
Name
@@ -43,7 +52,15 @@
</button>
</form>
{#if Object.keys(new_event).length > 0}
{#if Object.keys(new_event).length === 0}
<div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No event created yet...</strong>
{/if}
</div>
{:else}
<div class="rounded border-l-4 border-green-500 bg-green-100 p-4 text-green-700 mt-4">
<ol>
<li><strong>{new_event.name}</strong></li>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
let { new_event, participants, subject, body } = $props();
let { new_event, participants, subject, body, stepConditions } = $props();
</script>
<!-- New Event Overview -->
<div class="bg-white border border-gray-300 rounded p-6 mb-6">
<h2 class="text-xl font-bold mb-2">Event Overview</h2>
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Event Overview</h2>
<ul class="space-y-1">
<li><span class="font-semibold">Name:</span> {new_event.name}</li>
<li><span class="font-semibold">Date:</span> {new_event.date}</li>
@@ -13,23 +13,50 @@
</div>
<!-- Email Overview -->
<div class="bg-white border border-gray-300 rounded p-6 mb-6">
<h2 class="text-xl font-bold mb-2">Email Preview</h2>
<div class="mb-4 rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Email Preview</h2>
<div class="mb-2"><span class="font-semibold">Subject:</span> {subject}</div>
<div class="whitespace-pre-line border rounded p-2 bg-gray-50 text-gray-700"><span class="font-semibold">Body:</span>
<div class="mt-1">{body}</div>
<div class="rounded border bg-gray-50 p-2 whitespace-pre-line text-gray-700">
<span class="font-semibold"></span>
<div>{body}</div>
</div>
</div>
<!-- Participants Overview -->
<div class="bg-white border border-gray-300 rounded p-6">
<h2 class="text-xl font-bold mb-2">Participants ({participants.length})</h2>
<div class="rounded border border-gray-300 bg-white p-4">
<h2 class="mb-2 text-xl font-bold">Participants ({participants.length})</h2>
<ul class="space-y-1">
{#each participants as p}
<li class="flex items-center gap-2 border-b last:border-b-0 pb-1">
{#each participants.slice(0, 10) as p}
<li class="flex items-center gap-2 border-b pb-1 last:border-b-0">
<span class="font-semibold">{p.name} {p.surname}</span>
<span class="font-mono text-xs text-gray-600">{p.email}</span>
<span class="flex-1"></span>
<span class="text-right font-mono text-xs text-gray-600">{p.email}</span>
</li>
{/each}
</ul>
<p class="mt-2 text-sm text-gray-500">Note: Only the first 10 participants are shown.</p>
</div>
<button
class="mt-4 w-full rounded bg-blue-600 px-4 py-3 font-bold text-white
transition-colors duration-200 hover:bg-blue-700
disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500"
disabled={!stepConditions.every(Boolean)}
>
Generate QR codes and send
</button>
<div class="mt-2 space-y-1">
{#if !stepConditions[0]}
<p class="text-sm text-red-500">Please provide an event name before proceeding.</p>
{/if}
{#if !stepConditions[1]}
<p class="text-sm text-red-500">Please add at least one participant before proceeding.</p>
{/if}
{#if !stepConditions[2]}
<p class="text-sm text-red-500">Please provide an email subject before proceeding.</p>
{/if}
{#if !stepConditions[3]}
<p class="text-sm text-red-500">Please provide an email body before proceeding.</p>
{/if}
</div>

View File

@@ -2,11 +2,26 @@
import { enhance } from '$app/forms';
let { participants = [] } = $props();
let loading = $state(false);
function handleEnhance() {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}
</script>
<form method="POST" action="?/participants" use:enhance enctype="multipart/form-data" class="flex flex-col space-y-4 bg-white p-8 rounded border border-gray-300 w-full shadow-none">
<h2 class="text-2xl font-semibold text-center mb-4">Upload Participants</h2>
<form
method="POST"
action="?/participants"
use:enhance={handleEnhance}
enctype="multipart/form-data"
class="flex w-full flex-col space-y-4 rounded border border-gray-300 bg-white p-8 shadow-none"
>
<h2 class="mb-4 text-center text-2xl font-semibold">Upload Participants</h2>
<label class="flex flex-col text-gray-700">
CSV File
<input
@@ -14,30 +29,34 @@
name="participants"
id="participants"
accept=".csv"
class="mt-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-200"
class="mt-1 rounded border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-200 focus:outline-none"
required
/>
</label>
<button
type="submit"
class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition"
class="w-full rounded bg-blue-600 py-2 text-white transition hover:bg-blue-700"
>
Submit
</button>
</form>
{#if participants.length === 0}
<p class="text-gray-500 mt-4">No participants added yet.</p>
{/if}
{#if participants.length > 0}
<div class="rounded border-l-4 border-green-500 bg-green-50 p-4 text-green-700 mt-4">
<div class="mt-4 rounded border-l-4 border-gray-500 bg-gray-100 p-4 text-gray-700">
{#if loading}
<strong>Loading...</strong>
{:else}
<strong>No participants yet...</strong>
{/if}
</div>
{:else}
<div class="mt-4 rounded border-l-4 border-green-500 bg-green-50 p-4 text-green-700">
<ul class="space-y-2">
{#each participants as p, i}
<li class="flex items-center justify-between border-b pb-1">
<div>
<div class="font-semibold">{p.name} {p.surname}</div>
<div class="text-xs font-mono text-gray-600">{p.email}</div>
<div class="font-mono text-xs text-gray-600">{p.email}</div>
</div>
</li>
{/each}

View File

@@ -12,7 +12,6 @@
$effect(() => {
if (scanned_id === "") return;
console.log('New QR code found:', scanned_id);
scan_state = ScanState.scanning;
data.supabase