Compare commits
4 Commits
68e4d0b77b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90265110f | ||
|
|
97460c018c | ||
|
|
74910e3346 | ||
|
|
20b21de69e |
@@ -13,8 +13,8 @@ export const ESNCardAnniversary: Card = {
|
||||
height: 41 // mm
|
||||
},
|
||||
photo: {
|
||||
width: 26, // mm
|
||||
height: 36 // mm
|
||||
width: 27, // mm
|
||||
height: 37 // mm
|
||||
},
|
||||
textFields: {
|
||||
name: { x: 2, y: 4, size: 8 },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div
|
||||
class="container mx-auto max-w-5xl rounded-lg border border-gray-200 bg-white/90 p-10 text-center shadow-xl"
|
||||
>
|
||||
<div class="mb-10 flex flex-col items-center">
|
||||
<div class="mb-4 flex flex-col items-center">
|
||||
<!-- Animated ESN Logo -->
|
||||
<div
|
||||
class="mx-auto mb-6 flex h-40 w-40 items-center justify-center rounded-full bg-gradient-to-tr from-blue-400 via-purple-400 to-pink-400"
|
||||
@@ -35,32 +35,32 @@
|
||||
<span class="font-semibold text-black-800">Privacy-first</span>: all processing happens in
|
||||
your browser.
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="https://youtube.com"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-pink-100 px-4 py-2 font-semibold text-pink-700 transition-colors hover:bg-pink-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/></svg
|
||||
>
|
||||
Watch how Card Forge works
|
||||
</a>
|
||||
</div>
|
||||
<FeatureList class="mb-8" />
|
||||
<FeatureList class="mb-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href="https://youtube.com"
|
||||
target="_blank"
|
||||
class="flex w-64 items-center justify-center gap-2 rounded-lg bg-pink-400 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-pink-400"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
d="M23.498 6.186a2.998 2.998 0 0 0-2.115-2.117C19.073 3.5 12 3.5 12 3.5s-7.073 0-9.383.569A2.998 2.998 0 0 0 .502 6.186C0 8.497 0 12 0 12s0 3.503.502 5.814a2.998 2.998 0 0 0 2.115 2.117C4.927 20.5 12 20.5 12 20.5s7.073 0 9.383-.569a2.998 2.998 0 0 0 2.115-2.117C24 15.503 24 12 24 12s0-3.503-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/></svg
|
||||
>
|
||||
Watch Tutorial
|
||||
</a>
|
||||
<button
|
||||
onclick={startWizard}
|
||||
class="w-64 rounded-lg bg-blue-600 px-8 py-3 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105 hover:bg-blue-700"
|
||||
>
|
||||
Start Creating Cards
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={startWizard}
|
||||
class="rounded-lg bg-blue-600 bg-gradient-to-r px-10 py-4 text-lg font-bold text-white shadow-lg transition-transform hover:scale-105"
|
||||
>
|
||||
Start Creating Cards
|
||||
</button>
|
||||
</div>
|
||||
<footer class="mt-4 text-center">
|
||||
{#if buildDate && gitRef}
|
||||
|
||||
@@ -10,18 +10,6 @@
|
||||
import StepGallery from './wizard/StepGallery.svelte';
|
||||
import StepGenerate from './wizard/StepGenerate.svelte';
|
||||
|
||||
const stepComponents = {
|
||||
splash: Splash,
|
||||
auth: StepAuth,
|
||||
search: StepSheetSearch,
|
||||
mapping: StepColumnMap,
|
||||
validation: StepRowFilter,
|
||||
'card-details': StepCardDetails,
|
||||
'card-select': StepCardSelect,
|
||||
gallery: StepGallery,
|
||||
generate: StepGenerate
|
||||
};
|
||||
|
||||
const stepTitles = {
|
||||
splash: 'Welcome',
|
||||
auth: 'Authenticate',
|
||||
@@ -35,7 +23,6 @@
|
||||
};
|
||||
|
||||
let currentTitle = $derived(stepTitles[$currentStepName]);
|
||||
let currentComponent = $derived(stepComponents[$currentStepName]);
|
||||
let currentStepIndex = $derived(stepNames.indexOf($currentStepName));
|
||||
</script>
|
||||
|
||||
@@ -65,7 +52,25 @@
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<svelte:component this={currentComponent} />
|
||||
{#if $currentStepName === 'splash'}
|
||||
<Splash />
|
||||
{:else if $currentStepName === 'auth'}
|
||||
<StepAuth />
|
||||
{:else if $currentStepName === 'search'}
|
||||
<StepSheetSearch />
|
||||
{:else if $currentStepName === 'mapping'}
|
||||
<StepColumnMap />
|
||||
{:else if $currentStepName === 'validation'}
|
||||
<StepRowFilter />
|
||||
{:else if $currentStepName === 'card-details'}
|
||||
<StepCardDetails />
|
||||
{:else if $currentStepName === 'card-select'}
|
||||
<StepCardSelect />
|
||||
{:else if $currentStepName === 'gallery'}
|
||||
<StepGallery />
|
||||
{:else if $currentStepName === 'generate'}
|
||||
<StepGenerate />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,52 +201,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Format a date-like string into "DD MM YY" with basic heuristics for common inputs
|
||||
// Format a YYYY-MM-DD string into "DD MM YY"
|
||||
function formatDateDDMMYY(value: string): string {
|
||||
if (!value) return '';
|
||||
const trimmed = value.trim();
|
||||
|
||||
// ISO: YYYY-MM-DD or YYYY/MM/DD
|
||||
let m = trimmed.match(/^(\d{4})[\/\-](\d{2})[\/\-](\d{2})$/);
|
||||
if (m) {
|
||||
const [, y, mo, d] = m;
|
||||
return `${d} ${mo} ${y.slice(-2)}`;
|
||||
}
|
||||
|
||||
// DMY with separators: DD.MM.YYYY or DD/MM/YYYY or DD-MM-YYYY
|
||||
m = trimmed.match(/^(\d{2})[.\/-](\d{2})[.\/-](\d{4})$/);
|
||||
if (m) {
|
||||
const [, d, mo, y] = m;
|
||||
return `${d} ${mo} ${y.slice(-2)}`;
|
||||
}
|
||||
|
||||
// DMY two-digit year: DD.MM.YY or DD/MM/YY or DD-MM-YY
|
||||
m = trimmed.match(/^(\d{2})[.\/-](\d{2})[.\/-](\d{2})$/);
|
||||
if (m) {
|
||||
const [, d, mo, y2] = m;
|
||||
return `${d} ${mo} ${y2}`;
|
||||
}
|
||||
|
||||
// Try native Date parsing as a last resort
|
||||
const parsed = new Date(trimmed);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
const dd = String(parsed.getDate()).padStart(2, '0');
|
||||
const mm = String(parsed.getMonth() + 1).padStart(2, '0');
|
||||
const yy = String(parsed.getFullYear()).slice(-2);
|
||||
return `${dd} ${mm} ${yy}`;
|
||||
}
|
||||
|
||||
// Handle plain numeric serials (e.g., Google/Excel serial days since 1899-12-30)
|
||||
if (/^\d{5,}$/.test(trimmed)) {
|
||||
const days = parseInt(trimmed, 10);
|
||||
const base = new Date(Date.UTC(1899, 11, 30));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
const dd = String(base.getUTCDate()).padStart(2, '0');
|
||||
const mm = String(base.getUTCMonth() + 1).padStart(2, '0');
|
||||
const yy = String(base.getUTCFullYear()).slice(-2);
|
||||
return `${dd} ${mm} ${yy}`;
|
||||
// Expects YYYY-MM-DD and splits it
|
||||
const parts = trimmed.split('-');
|
||||
if (parts.length === 3) {
|
||||
const [y, mo, d] = parts;
|
||||
if (y.length === 4 && mo.length === 2 && d.length === 2) {
|
||||
return `${d} ${mo} ${y.slice(-2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for any other format that might slip through
|
||||
console.warn(`Unexpected date format received in formatDateDDMMYY: "${trimmed}"`);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import Navigator from './subcomponents/Navigator.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { parseAndFormatDate } from '$lib/utils/date';
|
||||
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -53,6 +54,9 @@
|
||||
const name = mapping.name !== -1 ? row[mapping.name] || '' : '';
|
||||
const pictureUrl = mapping.pictureUrl !== -1 ? row[mapping.pictureUrl] || '' : '';
|
||||
|
||||
const birthdayRaw = mapping.birthday !== -1 ? row[mapping.birthday] : '';
|
||||
const birthday = parseAndFormatDate(birthdayRaw);
|
||||
|
||||
if (!name && !pictureUrl) {
|
||||
return null; // Skip entirely empty rows
|
||||
}
|
||||
@@ -68,7 +72,7 @@
|
||||
id: uuid(),
|
||||
name,
|
||||
nationality: mapping.nationality !== -1 ? row[mapping.nationality] || '' : '',
|
||||
birthday: mapping.birthday !== -1 ? row[mapping.birthday] || '' : '',
|
||||
birthday,
|
||||
pictureUrl,
|
||||
alreadyPrinted,
|
||||
_rowIndex: index + 1,
|
||||
|
||||
@@ -76,8 +76,8 @@ export function getAbsolutePhotoDimensionsPt(
|
||||
|
||||
// Border configuration
|
||||
export const BORDER_CONFIG = {
|
||||
color: { r: 0.8, g: 0.8, b: 0.8 },
|
||||
width: 1 // in points
|
||||
color: { r: 0, g: 0, b: 0 },
|
||||
width: 0.5 // in points
|
||||
};
|
||||
|
||||
// Text configuration
|
||||
|
||||
68
src/lib/utils/date.ts
Normal file
68
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Parses a date string from various common formats and returns it in YYYY-MM-DD format.
|
||||
* Handles ISO (YYYY-MM-DD), European (DD.MM.YYYY), and US (MM/DD/YYYY) formats,
|
||||
* as well as Excel-style serial numbers.
|
||||
* @param value The date string to parse.
|
||||
* @returns The formatted date string or the original value if parsing fails.
|
||||
*/
|
||||
export function parseAndFormatDate(value: string | number | undefined): string {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
|
||||
const trimmed = value.toString().trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
let date: Date | null = null;
|
||||
|
||||
// 1. Try direct parsing (handles ISO 8601 like YYYY-MM-DD)
|
||||
const directParse = new Date(trimmed);
|
||||
if (!isNaN(directParse.getTime()) && trimmed.match(/^\d{4}/)) {
|
||||
date = directParse;
|
||||
}
|
||||
|
||||
// 2. Regex for MM/DD/YYYY or MM.DD.YYYY or MM-DD-YYYY (common in Google Forms)
|
||||
if (!date) {
|
||||
const mdyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||
if (mdyMatch) {
|
||||
const [, m, d, y] = mdyMatch;
|
||||
// Basic validation to avoid mixing up DMY and MDY for ambiguous dates like 01/02/2023
|
||||
// If the first part is > 12, it's likely a day (DMY), so we'll let the next block handle it.
|
||||
if (parseInt(m) <= 12) {
|
||||
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Regex for DD/MM/YYYY or DD.MM.YYYY or DD-MM-YYYY
|
||||
if (!date) {
|
||||
const dmyMatch = trimmed.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})$/);
|
||||
if (dmyMatch) {
|
||||
const [, d, m, y] = dmyMatch;
|
||||
const year = y.length === 2 ? parseInt(`20${y}`) : parseInt(y);
|
||||
// Month is 0-indexed in JS
|
||||
date = new Date(year, parseInt(m) - 1, parseInt(d));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle Excel serial date number (days since 1900-01-01, with Excel's leap year bug)
|
||||
if (!date && /^\d{5}$/.test(trimmed)) {
|
||||
const serial = parseInt(trimmed, 10);
|
||||
// Excel's epoch starts on day 1, which it considers 1900-01-01.
|
||||
// JS Date epoch is 1970-01-01.
|
||||
// Days between 1900-01-01 and 1970-01-01 is 25569.
|
||||
// Excel incorrectly thinks 1900 was a leap year, so we subtract 1 for dates after Feb 1900.
|
||||
const excelEpochDiff = serial > 60 ? 25567 : 25568;
|
||||
const utcMilliseconds = (serial - excelEpochDiff) * 86400 * 1000;
|
||||
date = new Date(utcMilliseconds);
|
||||
}
|
||||
|
||||
// If we have a valid date, format it. Otherwise, return original.
|
||||
if (date && !isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
return trimmed; // Fallback
|
||||
}
|
||||
Reference in New Issue
Block a user