Google auth done

This commit is contained in:
Roman Krček
2025-07-17 15:40:54 +02:00
parent 9f4b3a3804
commit f83595f3c3
20 changed files with 1956 additions and 4 deletions

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
function startWizard() {
currentStep.set(1); // Move to auth step
}
</script>
<div class="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div class="container mx-auto max-w-2xl bg-white p-8 rounded-lg shadow-lg text-center">
<div class="mb-8">
<!-- ESN Logo placeholder -->
<div class="mx-auto mb-6 w-24 h-24 bg-blue-600 rounded-full flex items-center justify-center">
<span class="text-white text-2xl font-bold">ESN</span>
</div>
<h1 class="mb-6 text-3xl font-bold text-gray-800">
ESN Card Generator
</h1>
<p class="text-lg text-gray-700 leading-relaxed mb-6">
Transform your Google Sheets into professional ESN membership cards with photos.
Privacy-first: all processing happens in your browser.
</p>
<div class="text-sm text-gray-500 mb-8">
<p class="mb-2">✓ Import data from Google Sheets</p>
<p class="mb-2">✓ Automatic face detection and cropping</p>
<p class="mb-2">✓ Generate text and photo PDFs</p>
<p>✓ No data stored on our servers</p>
</div>
</div>
<button
on:click={startWizard}
class="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
Start Creating Cards
</button>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
import StepAuth from './wizard/StepAuth.svelte';
// Additional steps to be added as they are implemented
const steps = [
StepAuth
];
const stepTitles = [
'Authenticate'
];
function goToPreviousStep() {
if ($currentStep > 1) {
currentStep.update(n => n - 1);
}
}
</script>
<div class="min-h-screen bg-gray-50">
<div class="container mx-auto max-w-4xl p-4">
<!-- Progress indicator -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800">
{stepTitles[$currentStep - 1]}
</h1>
<span class="text-sm text-gray-500">
Step {$currentStep} of {steps.length}
</span>
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: {($currentStep / steps.length) * 100}%"
></div>
</div>
</div>
<!-- Step content -->
<div class="bg-white rounded-lg shadow-sm">
<svelte:component this={steps[$currentStep - 1]} />
</div>
<!-- Navigation -->
<div class="flex justify-between mt-6">
<button
on:click={goToPreviousStep}
disabled={$currentStep <= 1}
class="px-4 py-2 text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
← Previous
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { currentStep } from '$lib/stores.js';
import { isSignedIn, handleSignIn, handleSignOut, isGoogleApiReady } from '$lib/google';
function proceed() {
currentStep.set(2);
}
</script>
<div class="p-6">
<div class="max-w-md mx-auto text-center">
<div class="mb-6">
<div class="mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.017 11.215c-3.573-2.775-9.317-.362-9.317 4.686C2.7 21.833 6.943 24 12.017 24c5.076 0 9.319-2.167 9.319-8.099 0-5.048-5.744-7.461-9.319-4.686z"/>
<path d="M20.791 5.016c-1.395-1.395-3.61-1.428-5.024-.033l-1.984 1.984v-.002L12.017 8.73 10.25 6.965l-1.984-1.984c-1.414-1.395-3.629-1.362-5.024.033L1.498 6.758c-1.438 1.438-1.438 3.77 0 5.208l1.744 1.744c1.395 1.395 3.61 1.428 5.024.033l1.984-1.984v.002L12.017 9.996l1.767 1.765 1.984 1.984c1.414 1.395 3.629 1.362 5.024-.033l1.744-1.744c1.438-1.438 1.438-3.77 0-5.208L20.791 5.016z"/>
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900 mb-2">
Connect to Google
</h2>
<p class="text-sm text-gray-700 leading-relaxed mb-6">
Sign in with your Google account to access your Google Sheets and Google Drive for photo downloads.
</p>
<div class="text-xs text-gray-500 mb-6 space-y-1">
<p>Required permissions:</p>
<p>• View your Google Spreadsheets</p>
<p>• View and manage the files in your Google Drive</p>
</div>
</div>
{#if $isSignedIn}
<!-- Authenticated state -->
<div class="bg-green-50 border border-green-300 rounded-lg p-4 mb-4">
<div class="flex items-center justify-center mb-2">
<svg class="w-5 h-5 text-green-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium text-green-800">Authenticated</span>
</div>
<p class="text-sm text-green-800 mb-3">
You are signed in.
</p>
<div class="flex space-x-3 justify-center">
<button
on:click={proceed}
class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700"
>
Continue →
</button>
<button
on:click={handleSignOut}
class="text-red-600 hover:text-red-700 px-4 py-2 text-sm font-medium"
>
Sign Out
</button>
</div>
</div>
{:else}
<!-- Unauthenticated state -->
<button
on:click={handleSignIn}
disabled={!$isGoogleApiReady}
class="w-full bg-blue-600 text-white px-4 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{#if $isGoogleApiReady}
Sign In with Google
{:else}
Loading Google API...
{/if}
</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,4 @@
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Map Columns</h2>
<p class="text-sm text-gray-700">Column mapping functionality will be implemented here.</p>
</div>

View File

@@ -0,0 +1,4 @@
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Review Photos</h2>
<p class="text-sm text-gray-700">Photo gallery and review functionality will be implemented here.</p>
</div>

View File

@@ -0,0 +1,4 @@
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Generate PDFs</h2>
<p class="text-sm text-gray-700">PDF generation functionality will be implemented here.</p>
</div>

View File

@@ -0,0 +1,4 @@
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900">Filter Rows</h2>
<p class="text-sm text-gray-700">Row filtering functionality will be implemented here.</p>
</div>

112
src/lib/google.ts Normal file
View File

@@ -0,0 +1,112 @@
import { writable } from 'svelte/store';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
export const isGoogleApiReady = writable(false);
export const isSignedIn = writable(false);
let tokenClient: google.accounts.oauth2.TokenClient;
const TOKEN_KEY = 'google_oauth_token';
export function initGoogleClient(callback: () => void) {
const script = document.createElement('script');
script.src = 'https://apis.google.com/js/api.js';
script.onload = () => {
gapi.load('client', async () => {
await gapi.client.init({
// NOTE: API KEY IS NOT REQUIRED FOR THIS IMPLEMENTATION
// apiKey: 'YOUR_API_KEY',
discoveryDocs: [
'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest',
'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest',
],
});
isGoogleApiReady.set(true);
// Restore token from storage if available
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
try {
const data = JSON.parse(saved);
if (data.access_token && data.expires_at && data.expires_at > Date.now()) {
gapi.client.setToken({ access_token: data.access_token });
isSignedIn.set(true);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch {
localStorage.removeItem(TOKEN_KEY);
}
}
callback();
});
};
document.body.appendChild(script);
const scriptGsi = document.createElement('script');
scriptGsi.src = 'https://accounts.google.com/gsi/client';
scriptGsi.onload = () => {
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: GOOGLE_CLIENT_ID,
scope: 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly',
callback: (tokenResponse) => {
if (tokenResponse?.access_token) {
// Set token in gapi client
gapi.client.setToken({ access_token: tokenResponse.access_token });
isSignedIn.set(true);
// Persist token with expiration
const expiresInSeconds = tokenResponse.expires_in
? Number(tokenResponse.expires_in)
: 0;
const expiresInMs = expiresInSeconds * 1000;
const record = {
access_token: tokenResponse.access_token,
expires_at: expiresInMs ? Date.now() + expiresInMs : Date.now() + 3600 * 1000
};
localStorage.setItem(TOKEN_KEY, JSON.stringify(record));
}
},
});
};
document.body.appendChild(scriptGsi);
}
export function handleSignIn() {
if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' });
} else {
tokenClient.requestAccessToken({ prompt: '' });
}
}
export function handleSignOut() {
const token = gapi.client.getToken();
if (token !== null) {
google.accounts.oauth2.revoke(token.access_token, () => {
gapi.client.setToken(null);
isSignedIn.set(false);
});
}
}
export async function searchSheets(query: string) {
if (!gapi.client.drive) {
throw new Error('Google Drive API not loaded');
}
const response = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`,
fields: 'files(id, name, iconLink, webViewLink)',
pageSize: 20,
});
return response.result.files || [];
}
export async function getSheetData(spreadsheetId: string, range: string) {
if (!gapi.client.sheets) {
throw new Error('Google Sheets API not loaded');
}
const response = await gapi.client.sheets.spreadsheets.values.get({
spreadsheetId,
range,
});
return response.result.values || [];
}

142
src/lib/stores.ts Normal file
View File

@@ -0,0 +1,142 @@
import { writable, derived } from 'svelte/store';
// User session and authentication
export const session = writable<{
token?: string;
user?: { name: string; email: string };
}>({});
// Raw sheet data after import
export const rawSheetData = writable<string[][]>([]);
// Column mapping configuration
export const columnMapping = writable<{
name?: number;
surname?: number;
nationality?: number;
birthday?: number;
pictureUrl?: number;
}>({});
// Processed row data after mapping and validation
export interface RowData {
id: string;
name: string;
surname: string;
nationality: string;
birthday: string;
pictureUrl: string;
valid: boolean;
included: boolean;
age?: number;
validationErrors: string[];
}
export const sheetData = writable<RowData[]>([]);
// Picture storage and metadata
export interface PictureBlobInfo {
id: string;
blob: Blob;
url: string;
downloaded: boolean;
faceDetected: boolean;
faceCount: number;
}
export const pictures = writable<Record<string, PictureBlobInfo>>({});
// Crop rectangles for each photo
export interface Crop {
x: number;
y: number;
width: number;
height: number;
}
export const cropRects = writable<Record<string, Crop>>({});
// Wizard state management
export const currentStep = writable<number>(0);
export const steps = [
'splash',
'auth',
'search',
'mapping',
'validation',
'gallery',
'generate'
] as const;
export type WizardStep = typeof steps[number];
export const currentStepName = derived(
currentStep,
($currentStep) => steps[$currentStep]
);
// Progress tracking
export interface ProgressState {
stage: string;
current: number;
total: number;
message: string;
}
export const progress = writable<ProgressState>({
stage: '',
current: 0,
total: 0,
message: ''
});
// Google Sheets list for search
export interface SheetInfo {
id: string;
name: string;
url: string;
}
export const availableSheets = writable<SheetInfo[]>([]);
// Selected sheet
export const selectedSheet = writable<SheetInfo | null>(null);
// Validation derived stores
export const validRowCount = derived(
sheetData,
($sheetData) => $sheetData.filter(row => row.valid && row.included).length
);
export const invalidRowCount = derived(
sheetData,
($sheetData) => $sheetData.filter(row => !row.valid).length
);
export const totalRowCount = derived(
sheetData,
($sheetData) => $sheetData.length
);
// Face detection status
export const faceDetectionProgress = writable<{
completed: number;
total: number;
currentImage: string;
}>({
completed: 0,
total: 0,
currentImage: ''
});
// PDF generation status
export const pdfGenerationStatus = writable<{
generating: boolean;
stage: 'preparing' | 'text-pdf' | 'photo-pdf' | 'complete';
progress: number;
}>({
generating: false,
stage: 'preparing',
progress: 0
});