Google auth done
This commit is contained in:
12
src/hooks.server.ts
Normal file
12
src/hooks.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
|
||||
// Allow popups to use window.opener for Google Identity Services
|
||||
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
|
||||
// Disable Cross-Origin-Embedder-Policy for this application
|
||||
response.headers.set('Cross-Origin-Embedder-Policy', 'unsafe-none');
|
||||
|
||||
return response;
|
||||
};
|
||||
41
src/lib/components/Splash.svelte
Normal file
41
src/lib/components/Splash.svelte
Normal 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>
|
||||
59
src/lib/components/Wizard.svelte
Normal file
59
src/lib/components/Wizard.svelte
Normal 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>
|
||||
80
src/lib/components/wizard/StepAuth.svelte
Normal file
80
src/lib/components/wizard/StepAuth.svelte
Normal 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>
|
||||
4
src/lib/components/wizard/StepColumnMap.svelte
Normal file
4
src/lib/components/wizard/StepColumnMap.svelte
Normal 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>
|
||||
4
src/lib/components/wizard/StepGallery.svelte
Normal file
4
src/lib/components/wizard/StepGallery.svelte
Normal 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>
|
||||
4
src/lib/components/wizard/StepGenerate.svelte
Normal file
4
src/lib/components/wizard/StepGenerate.svelte
Normal 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>
|
||||
4
src/lib/components/wizard/StepRowFilter.svelte
Normal file
4
src/lib/components/wizard/StepRowFilter.svelte
Normal 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>
|
||||
0
src/lib/components/wizard/StepSheetSearch.svelte
Normal file
0
src/lib/components/wizard/StepSheetSearch.svelte
Normal file
112
src/lib/google.ts
Normal file
112
src/lib/google.ts
Normal 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
142
src/lib/stores.ts
Normal 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
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { initGoogleClient } from '$lib/google';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
initGoogleClient(() => {
|
||||
// You can add any logic here to run after the client is initialized
|
||||
console.log('Google API client initialized');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<script lang="ts">
|
||||
import Splash from '$lib/components/Splash.svelte';
|
||||
import Wizard from '$lib/components/Wizard.svelte';
|
||||
import { currentStep } from '$lib/stores';
|
||||
</script>
|
||||
|
||||
{#if $currentStep === 0}
|
||||
<Splash />
|
||||
{:else}
|
||||
<Wizard />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user