Compare commits

..

4 Commits

Author SHA1 Message Date
Levente Orban
cc3c868f7d feat: add option to link Google Maps to events 2025-09-24 21:15:31 +02:00
Levente Orban
69a760d3f1 Merge pull request #23 from polaroi8d/fix/windows-not-defined
fix: windows not defined in SSR
2025-09-24 20:21:34 +02:00
Levente Orban
a59bc3601c fix: windows not defined in SSR 2025-09-24 20:18:52 +02:00
Levente Orban
b64d48a933 chore:readme-icall-feature
chore: add ical feature to readme
2025-09-18 11:23:56 +02:00
14 changed files with 367 additions and 104 deletions

View File

@@ -14,6 +14,8 @@ CREATE TABLE IF NOT EXISTS events (
date DATE NOT NULL, date DATE NOT NULL,
time TIME NOT NULL, time TIME NOT NULL,
location VARCHAR(200) NOT NULL, location VARCHAR(200) NOT NULL,
location_type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (location_type IN ('text','maps')),
location_url VARCHAR(500),
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')), type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
attendee_limit INTEGER CHECK (attendee_limit > 0), attendee_limit INTEGER CHECK (attendee_limit > 0),
user_id VARCHAR(100) NOT NULL, user_id VARCHAR(100) NOT NULL,
@@ -37,6 +39,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
-- ======================================= -- =======================================
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id); CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_date ON events(date); CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id); CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id); CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);

View File

@@ -17,6 +17,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
// --- Enums (matching the SQL CHECK constraints) // --- Enums (matching the SQL CHECK constraints)
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']); export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
export const visibilityEnum = pgEnum('visibility', ['public', 'private']); export const visibilityEnum = pgEnum('visibility', ['public', 'private']);
export const locationTypeEnum = pgEnum('location_type', ['text', 'maps']);
// --- Events table // --- Events table
export const events = pgTable( export const events = pgTable(
@@ -27,6 +28,8 @@ export const events = pgTable(
date: date('date', { mode: 'string' }).notNull(), // ISO 'YYYY-MM-DD' date: date('date', { mode: 'string' }).notNull(), // ISO 'YYYY-MM-DD'
time: time('time', { withTimezone: false }).notNull(), // 'HH:MM:SS' time: time('time', { withTimezone: false }).notNull(), // 'HH:MM:SS'
location: varchar('location', { length: 200 }).notNull(), location: varchar('location', { length: 200 }).notNull(),
locationType: locationTypeEnum('location_type').notNull().default('text'),
locationUrl: varchar('location_url', { length: 500 }),
type: eventTypeEnum('type').notNull(), type: eventTypeEnum('type').notNull(),
attendeeLimit: integer('attendee_limit'), // nullable in SQL attendeeLimit: integer('attendee_limit'), // nullable in SQL
userId: varchar('user_id', { length: 100 }).notNull(), userId: varchar('user_id', { length: 100 }).notNull(),

View File

@@ -14,6 +14,13 @@
"date": "Date", "date": "Date",
"time": "Time", "time": "Time",
"location": "Location", "location": "Location",
"locationType": "Location Type",
"locationText": "Text",
"locationMaps": "Google Maps",
"locationTextDescription": "Enter location as plain text.",
"locationMapsDescription": "Enter Google Maps link.",
"googleMapsUrl": "Google Maps URL",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"type": "Type", "type": "Type",
"visibility": "Visibility", "visibility": "Visibility",
"public": "Public", "public": "Public",
@@ -134,6 +141,13 @@
"timeLabel": "Time", "timeLabel": "Time",
"locationLabel": "Location", "locationLabel": "Location",
"locationPlaceholder": "Enter location", "locationPlaceholder": "Enter location",
"locationTypeLabel": "Location Type",
"locationTextOption": "Plain Text",
"locationMapsOption": "Google Maps",
"locationTextDescription": "Enter location as plain text",
"locationMapsDescription": "Enter Google Maps link",
"googleMapsUrlLabel": "Google Maps URL",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"typeLabel": "Type", "typeLabel": "Type",
"unlimitedOption": "Unlimited", "unlimitedOption": "Unlimited",
"limitedOption": "Limited", "limitedOption": "Limited",

View File

@@ -1,6 +1,7 @@
export type EventType = 'limited' | 'unlimited'; export type EventType = 'limited' | 'unlimited';
export type EventVisibility = 'public' | 'private'; export type EventVisibility = 'public' | 'private';
export type ActionType = 'add' | 'remove'; export type ActionType = 'add' | 'remove';
export type LocationType = 'text' | 'maps';
export interface Event { export interface Event {
id: string; id: string;
@@ -8,6 +9,8 @@ export interface Event {
date: string; date: string;
time: string; time: string;
location: string; location: string;
location_type: LocationType;
location_url?: string;
type: EventType; type: EventType;
attendee_limit?: number; attendee_limit?: number;
visibility: EventVisibility; visibility: EventVisibility;
@@ -29,6 +32,8 @@ export interface CreateEventData {
date: string; date: string;
time: string; time: string;
location: string; location: string;
location_type: LocationType;
location_url?: string;
type: EventType; type: EventType;
attendee_limit?: number; attendee_limit?: number;
visibility: EventVisibility; visibility: EventVisibility;
@@ -40,6 +45,8 @@ export interface DatabaseEvent {
date: string; date: string;
time: string; time: string;
location: string; location: string;
location_type: LocationType;
location_url?: string;
type: EventType; type: EventType;
attendee_limit?: number; attendee_limit?: number;
visibility: EventVisibility; visibility: EventVisibility;

View File

@@ -21,6 +21,8 @@ export const actions: Actions = {
const date = formData.get('date') as string; const date = formData.get('date') as string;
const time = formData.get('time') as string; const time = formData.get('time') as string;
const location = formData.get('location') as string; const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited'; const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string; const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private'; const visibility = formData.get('visibility') as 'public' | 'private';
@@ -33,6 +35,8 @@ export const actions: Actions = {
if (!date) missingFields.push('date'); if (!date) missingFields.push('date');
if (!time) missingFields.push('time'); if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location'); if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (!userId) missingFields.push('userId'); if (!userId) missingFields.push('userId');
if (missingFields.length > 0) { if (missingFields.length > 0) {
@@ -43,6 +47,8 @@ export const actions: Actions = {
date, date,
time, time,
location, location,
location_type: locationType,
location_url: locationUrl,
type, type,
attendee_limit: attendeeLimit, attendee_limit: attendeeLimit,
visibility visibility
@@ -53,14 +59,34 @@ export const actions: Actions = {
if (new Date(date) < new Date()) { if (new Date(date) < new Date()) {
return fail(400, { return fail(400, {
error: 'Date cannot be in the past.', error: 'Date cannot be in the past.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility } values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
}); });
} }
if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) { if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) {
return fail(400, { return fail(400, {
error: 'Limit must be at least 2 for limited events.', error: 'Limit must be at least 2 for limited events.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility } values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
}); });
} }
@@ -74,6 +100,8 @@ export const actions: Actions = {
date: date, date: date,
time: time, time: time,
location: location.trim(), location: location.trim(),
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type, type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null, attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility, visibility: visibility,

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { CreateEventData, EventType } from '$lib/types'; import type { CreateEventData, EventType, LocationType } from '$lib/types';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js'; import { t } from '$lib/i18n/i18n.js';
@@ -11,6 +11,8 @@
date: '', date: '',
time: '', time: '',
location: '', location: '',
location_type: 'text',
location_url: '',
type: 'unlimited', type: 'unlimited',
attendee_limit: undefined, attendee_limit: undefined,
visibility: 'public' visibility: 'public'
@@ -46,6 +48,16 @@
} }
}; };
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
eventData.location = 'Google Maps';
}
};
const handleCancel = () => { const handleCancel = () => {
goto(`/discover`); goto(`/discover`);
}; };
@@ -83,6 +95,7 @@
<input type="hidden" name="userId" value={currentUserId} /> <input type="hidden" name="userId" value={currentUserId} />
<input type="hidden" name="type" value={eventData.type} /> <input type="hidden" name="type" value={eventData.type} />
<input type="hidden" name="visibility" value={eventData.visibility} /> <input type="hidden" name="visibility" value={eventData.visibility} />
<input type="hidden" name="location_type" value={eventData.location_type} />
{#if errors.server} {#if errors.server}
<div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700"> <div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700">
@@ -148,54 +161,112 @@
</div> </div>
</div> </div>
<!-- Location --> <!-- Location Type -->
<div>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'text'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('text')}
>
{t('create.locationTextOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'maps'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleLocationTypeChange('maps')}
>
{t('create.locationMapsOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input -->
<div> <div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold"> <label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationLabel')} <span class="text-red-400">{t('common.required')}</span> {eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label> </label>
<input {#if eventData.location_type === 'text'}
id="location" <input
name="location" id="location"
type="text" name="location"
bind:value={eventData.location} type="text"
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all" bind:value={eventData.location}
placeholder={t('create.locationPlaceholder')} class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
maxlength="200" placeholder={t('create.locationPlaceholder')}
required maxlength="200"
/> required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location} {#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p> <p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if} {/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div> </div>
<!-- Event Type --> <!-- Event Type -->
<div> <div>
<label class="text-dark-800 mb-3 block text-sm font-semibold"> <fieldset>
{t('create.typeLabel')} <legend class="text-dark-800 mb-3 block text-sm font-semibold">
<span class="text-red-400">{t('common.required')}</span></label {t('create.typeLabel')}
> <span class="text-red-400">{t('common.required')}</span>
<div class="grid grid-cols-2 gap-3"> </legend>
<button <div class="grid grid-cols-2 gap-3">
type="button" <button
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type === type="button"
'unlimited' class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70' 'unlimited'
: 'border-dark-300 text-dark-700'}" ? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
on:click={() => handleTypeChange('unlimited')} : 'border-dark-300 text-dark-700'}"
> on:click={() => handleTypeChange('unlimited')}
{t('create.unlimitedOption')} >
</button> {t('create.unlimitedOption')}
<button </button>
type="button" <button
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type === type="button"
'limited' class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70' 'limited'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}" ? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
on:click={() => handleTypeChange('limited')} : 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
> on:click={() => handleTypeChange('limited')}
{t('create.limitedOption')} >
</button> {t('create.limitedOption')}
</div> </button>
</div>
</fieldset>
</div> </div>
<!-- Limit (only for limited events) --> <!-- Limit (only for limited events) -->
@@ -224,37 +295,39 @@
<!-- Event Visibility --> <!-- Event Visibility -->
<div> <div>
<label class="text-dark-800 mb-3 block text-sm font-semibold"> <fieldset>
{t('create.visibilityLabel')} <legend class="text-dark-800 mb-3 block text-sm font-semibold">
<span class="text-red-400">{t('common.required')}</span></label {t('create.visibilityLabel')}
> <span class="text-red-400">{t('common.required')}</span>
<div class="grid grid-cols-2 gap-3"> </legend>
<button <div class="grid grid-cols-2 gap-3">
type="button" <button
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility === type="button"
'public' class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70' 'public'
: 'border-dark-300 text-dark-700'}" ? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
on:click={() => (eventData.visibility = 'public')} : 'border-dark-300 text-dark-700'}"
> on:click={() => (eventData.visibility = 'public')}
{t('create.publicOption')} >
</button> {t('create.publicOption')}
<button </button>
type="button" <button
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility === type="button"
'private' class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70' 'private'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}" ? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
on:click={() => (eventData.visibility = 'private')} : 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
> on:click={() => (eventData.visibility = 'private')}
{t('create.privateOption')} >
</button> {t('create.privateOption')}
</div> </button>
<p class="mt-2 text-xs text-slate-400"> </div>
{eventData.visibility === 'public' <p class="mt-2 text-xs text-slate-400">
? t('create.publicDescription') {eventData.visibility === 'public'
: t('create.privateDescription')} ? t('create.publicDescription')
</p> : t('create.privateDescription')}
</p>
</fieldset>
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">

View File

@@ -19,6 +19,8 @@ export const load: PageServerLoad = async () => {
date: event.date, // Already in 'YYYY-MM-DD' format date: event.date, // Already in 'YYYY-MM-DD' format
time: event.time, // Already in 'HH:MM:SS' format time: event.time, // Already in 'HH:MM:SS' format
location: event.location, location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type, type: event.type,
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
visibility: event.visibility, visibility: event.visibility,

View File

@@ -289,7 +289,18 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path> ></path>
</svg> </svg>
<span>{event.location}</span> {#if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
{event.location}
</a>
{:else}
<span>{event.location}</span>
{/if}
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span

View File

@@ -24,6 +24,8 @@ export const load = async ({ cookies }) => {
date: event.date, date: event.date,
time: event.time, time: event.time,
location: event.location, location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type, type: event.type,
attendee_limit: event.attendeeLimit, attendee_limit: event.attendeeLimit,
visibility: event.visibility, visibility: event.visibility,

View File

@@ -126,7 +126,18 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path> ></path>
</svg> </svg>
<span>{event.location}</span> {#if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
{event.location}
</a>
{:else}
<span>{event.location}</span>
{/if}
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span

View File

@@ -32,6 +32,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
date: event.date, date: event.date,
time: event.time, time: event.time,
location: event.location, location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type, type: event.type,
attendee_limit: event.attendeeLimit, attendee_limit: event.attendeeLimit,
visibility: event.visibility, visibility: event.visibility,

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment';
import type { Event, RSVP } from '$lib/types'; import type { Event, RSVP } from '$lib/types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
@@ -28,13 +29,13 @@
$: currentUserId = data.userId; $: currentUserId = data.userId;
// Create calendar event object when event data changes // Create calendar event object when event data changes
$: if (event) { $: if (event && browser) {
calendarEvent = { calendarEvent = {
name: event.name, name: event.name,
date: event.date, date: event.date,
time: event.time, time: event.time,
location: event.location, location: event.location,
url: `${window.location.origin}/event/${eventId}` url: `${$page.url.origin}/event/${eventId}`
}; };
} }
@@ -56,13 +57,15 @@
const eventId = $page.params.id || ''; const eventId = $page.params.id || '';
const copyEventLink = () => { const copyEventLink = () => {
const url = `${window.location.origin}/event/${eventId}`; if (browser) {
navigator.clipboard.writeText(url).then(() => { const url = `${$page.url.origin}/event/${eventId}`;
success = 'Event link copied to clipboard!'; navigator.clipboard.writeText(url).then(() => {
setTimeout(() => { success = 'Event link copied to clipboard!';
success = ''; setTimeout(() => {
}, 3000); success = '';
}); }, 3000);
});
}
}; };
const clearMessages = () => { const clearMessages = () => {
@@ -152,7 +155,18 @@
</svg> </svg>
</div> </div>
<div> <div>
<p class="font-semibold text-white">{event.location}</p> {#if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
>
{event.location}
</a>
{:else}
<p class="font-semibold text-white">{event.location}</p>
{/if}
</div> </div>
</div> </div>
@@ -408,12 +422,12 @@
</div> </div>
<!-- Calendar Modal --> <!-- Calendar Modal -->
{#if calendarEvent} {#if calendarEvent && browser}
<CalendarModal <CalendarModal
bind:isOpen={showCalendarModal} bind:isOpen={showCalendarModal}
event={calendarEvent} event={calendarEvent}
{eventId} {eventId}
baseUrl={window.location.origin} baseUrl={$page.url.origin}
on:close={closeCalendarModal} on:close={closeCalendarModal}
/> />
{/if} {/if}

View File

@@ -53,6 +53,8 @@ export const actions: Actions = {
const date = formData.get('date') as string; const date = formData.get('date') as string;
const time = formData.get('time') as string; const time = formData.get('time') as string;
const location = formData.get('location') as string; const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited'; const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string; const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private'; const visibility = formData.get('visibility') as 'public' | 'private';
@@ -64,6 +66,8 @@ export const actions: Actions = {
if (!date) missingFields.push('date'); if (!date) missingFields.push('date');
if (!time) missingFields.push('time'); if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location'); if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (missingFields.length > 0) { if (missingFields.length > 0) {
return fail(400, { return fail(400, {
@@ -73,6 +77,8 @@ export const actions: Actions = {
date, date,
time, time,
location, location,
location_type: locationType,
location_url: locationUrl,
type, type,
attendee_limit: attendeeLimit, attendee_limit: attendeeLimit,
visibility visibility
@@ -88,14 +94,34 @@ export const actions: Actions = {
if (eventDate < today) { if (eventDate < today) {
return fail(400, { return fail(400, {
error: 'Date cannot be in the past.', error: 'Date cannot be in the past.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility } values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
}); });
} }
if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) { if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) {
return fail(400, { return fail(400, {
error: 'Limit must be at least 2 for limited events.', error: 'Limit must be at least 2 for limited events.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility } values: {
name,
date,
time,
location,
location_type: locationType,
location_url: locationUrl,
type,
attendee_limit: attendeeLimit,
visibility
}
}); });
} }
@@ -107,6 +133,8 @@ export const actions: Actions = {
date: date, date: date,
time: time, time: time,
location: location.trim(), location: location.trim(),
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type, type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null, attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility, visibility: visibility,

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { EventType } from '$lib/types'; import type { EventType, LocationType } from '$lib/types';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js'; import { t } from '$lib/i18n/i18n.js';
@@ -12,6 +12,8 @@
date: data.event.date, date: data.event.date,
time: data.event.time, time: data.event.time,
location: data.event.location, location: data.event.location,
location_type: data.event.locationType || 'text',
location_url: data.event.locationUrl || '',
type: data.event.type, type: data.event.type,
attendee_limit: data.event.attendeeLimit, attendee_limit: data.event.attendeeLimit,
visibility: data.event.visibility visibility: data.event.visibility
@@ -49,6 +51,16 @@
} }
}; };
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
eventData.location = 'Google Maps';
}
};
const handleCancel = () => { const handleCancel = () => {
goto(`/event/${data.event.id}`); goto(`/event/${data.event.id}`);
}; };
@@ -86,9 +98,6 @@
}} }}
class="space-y-6" class="space-y-6"
> >
<input type="hidden" name="type" value={eventData.type} />
<input type="hidden" name="visibility" value={eventData.visibility} />
{#if errors.server} {#if errors.server}
<div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700"> <div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700">
{errors.server} {errors.server}
@@ -153,24 +162,80 @@
</div> </div>
</div> </div>
<!-- Location --> <!-- Location Type -->
<div>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'text'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('text')}
>
{t('create.locationTextOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'maps'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => handleLocationTypeChange('maps')}
>
{t('create.locationMapsOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input -->
<div> <div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold"> <label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('common.location')} <span class="text-red-400">{t('common.required')}</span> {eventData.location_type === 'text'
? t('create.locationTypeLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label> </label>
<input {#if eventData.location_type === 'text'}
id="location" <input
name="location" id="location"
type="text" name="location"
bind:value={eventData.location} type="text"
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all" bind:value={eventData.location}
placeholder={t('common.enterLocation')} class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
maxlength="200" placeholder={t('create.locationPlaceholder')}
required maxlength="200"
/> required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location} {#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p> <p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if} {/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div> </div>
<!-- Event Type --> <!-- Event Type -->