Compare commits

...

15 Commits

Author SHA1 Message Date
Levente Orban
8176b2317f fix: linting issue 2025-09-02 11:04:15 +02:00
Levente Orban
e20018735e feat: add edit events functionality 2025-09-02 10:44:37 +02:00
Levente Orban
bd9796a8d1 feat: add a time filter and date/time sort option to /discovery page
feat: add a time filter and date/time sort option to /discovery page
2025-09-01 15:54:54 +02:00
Levente Orban
7d1991eb94 feat: add a filter toggle 2025-09-01 15:10:13 +02:00
Levente Orban
6020a78302 feat: add a time filter and date/time sort option to /discovery page 2025-09-01 14:20:15 +02:00
Levente Orban
f8b122ed45 Merge pull request #9 from polaroi8d/fix/remove-cactus
fix: remove cactus & netlify adapter and unused fonts
2025-09-01 12:11:14 +02:00
Levente Orban
d998aff383 fix: remove cactus & netlify adapter and unused fonts 2025-09-01 11:43:47 +02:00
Levente Orban
4a2defe1f8 Merge pull request #8 from nandor-magyar/nandor-magyar/main
fix: small adjusments, renames for the /healthz and readme
2025-09-01 11:03:33 +02:00
Levente Orban
5c178d8a79 fix: small adjusments, renames for the /healthz and readme 2025-09-01 11:01:38 +02:00
Levente Orban
8a76421571 fix: small adjusments, renames for the /healthz and readme 2025-09-01 10:43:40 +02:00
Nandor Magyar
94fffc5695 add healthz, docker fixes, minor readme tweaks 2025-09-01 10:43:39 +02:00
Levente Orban
cc8266ae6e Merge pull request #7 from polaroi8d/feat/fuse-search
feat: add fuse.js search module
2025-08-31 18:58:05 +02:00
Levente Orban
16ad15071c feat: add fuse.js search module 2025-08-31 18:50:47 +02:00
Levente Orban
834b9e0715 feat: add search bar for /discovery and seed.sql for populating random data 2025-08-31 17:58:59 +02:00
Levente Orban
8435289e1e fix: typo and add wikipedia referral for the naming 2025-08-29 09:10:01 +02:00
27 changed files with 944 additions and 150 deletions

View File

@@ -1,13 +1,13 @@
# Postgres configuration
POSTGRES_DB=cactoied_database
POSTGRES_DB=cactoide_database
POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432
# localhost
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoied_database"
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
# docker
# DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoied_database"
# DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoide_database"
# Application configuration
APP_VERSION=latest

View File

@@ -20,4 +20,8 @@ EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1
CMD [ "node", "build" ]

View File

@@ -1,4 +1,4 @@
.PHONY: help build up db-only logs clean
.PHONY: help build up db-only logs db-clean prune
# Default target
help:
@@ -13,32 +13,37 @@ help:
@echo ""
@echo "Utility commands:"
@echo " make logs - Show logs from all services"
@echo " make clean - Remove all containers, images, and volumes"
@echo " make db-clean - Stop & remove database container"
@echo " make prune - Remove all containers, images, and volumes"
@echo " make help - Show this help message"
# Build the Docker images
build:
@echo "Building Docker images..."
docker-compose build
docker compose build
# Start all services
up:
@echo "Starting all services..."
docker-compose up -d
docker compose up -d
# Start only the database
db-only:
@echo "Starting only the database..."
docker-compose up -d postgres
docker compose up -d postgres
# Show logs from all services
logs:
@echo "Showing logs from all services..."
docker-compose logs -f
docker compose logs -f
db-clean:
@echo "Cleaning up all Docker resources..."
docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f
# Clean up everything (containers, images, volumes)
clean:
prune:
@echo "Cleaning up all Docker resources..."
docker-compose down -v --rmi all
docker compose down -v --rmi all

View File

@@ -27,10 +27,24 @@ A mobile-first event RSVP platform that lets you create events, share unique URL
### Quick Start
#### Requirements
`git, docker, docker-compose, node at least suggested 20.19.0`
Uses the [`docker-compose.yml`](docker-compose.yml) file to setup the application with the database. You can define all ENV variables in the [`.env`](.env.example) file from the `.env.example`.
```bash
git clone https://github.com/polaroi8d/cactoide/
cd cactoide
cp env.example .env
docker compose up -d
```
### Development
```bash
git clone https://github.com/polaroi8d/cactoide/
cd cactoide
npm install
cp env.example .env
make db-only
npm run dev -- --open
@@ -38,9 +52,7 @@ npm run dev -- --open
Your app will be available at `http://localhost:5173`. You can use the Makefile commands to run the application or the database, eg.: `make db-only`.
### Self-Host
Use the [`docker-compose.yml`](docker-compose.yml) file to setup the application with the database. You can define all ENV variables in the [`.env`](.env.example) file from the `.env.example`.
Use the `database/seed.sql` if you want to populate your database with dummy data.
### License

77
database/seed.sql Normal file
View File

@@ -0,0 +1,77 @@
BEGIN;
-- Optional: start clean (will also remove RSVPs via CASCADE)
TRUNCATE TABLE events CASCADE;
-- -----------------------------
-- Seed 100 events
-- -----------------------------
WITH params AS (
SELECT
ARRAY[
'Budapest', 'Berlin', 'Paris', 'Madrid', 'Rome', 'Vienna', 'Prague',
'Warsaw', 'Amsterdam', 'Lisbon', 'Copenhagen', 'Dublin', 'Athens',
'Zurich', 'Helsinki', 'Oslo', 'Stockholm', 'Brussels', 'Munich', 'Milan'
]::text[] AS cities,
ARRAY['Hall','Park','Rooftop','Auditorium','Conference Center','Café','Online']::text[] AS venues,
ARRAY['Tech Talk','Meetup','Workshop','Concert','Yoga','Brunch','Game Night','Hackathon','Book Club','Networking']::text[] AS themes
),
to_insert AS (
SELECT
-- 8-char ID (hex)
LEFT(ENCODE(gen_random_bytes(4), 'hex'), 8) AS id,
-- Make varied names by mixing a theme and city
(SELECT themes[(gs % array_length(themes,1)) + 1] FROM params) || ' @ ' ||
(SELECT cities[(gs % array_length(cities,1)) + 1] FROM params) AS name,
-- Spread dates across past and future (centered around today)
(CURRENT_DATE + (gs - 50))::date AS date,
-- Varied times: between 08:00 and 19:xx
make_time( (8 + (gs % 12))::int, (gs*7 % 60)::int, 0)::time AS time,
-- City + venue
(SELECT cities[(gs % array_length(cities,1)) + 1] FROM params) || ' ' ||
(SELECT venues[(gs % array_length(venues,1)) + 1] FROM params) AS location,
-- Alternate types
CASE WHEN gs % 2 = 0 THEN 'limited' ELSE 'unlimited' END AS type,
-- Only set attendee_limit for limited events
CASE WHEN gs % 2 = 0 THEN 10 + (gs % 40) ELSE NULL END AS attendee_limit,
-- Rotate through 20 user_ids
'user_' || ((gs % 20) + 1)::text AS user_id,
-- Mix public/private
CASE WHEN gs % 3 = 0 THEN 'private' ELSE 'public' END AS visibility
FROM generate_series(1, 100) AS gs
)
INSERT INTO events (id, name, date, time, location, type, attendee_limit, user_id, visibility, created_at, updated_at)
SELECT id, name, date, time, location, type, attendee_limit, user_id, visibility, NOW(), NOW()
FROM to_insert;
-- -----------------------------
-- Seed RSVPs
-- - For limited events: 0..attendee_limit attendees
-- - For unlimited events: 0..75 attendees
-- -----------------------------
WITH ev AS (
SELECT e.id, e.type, e.attendee_limit
FROM events e
),
counts AS (
SELECT
id,
type,
attendee_limit,
CASE
WHEN type = 'limited' THEN GREATEST(0, LEAST(attendee_limit, FLOOR(random() * (COALESCE(attendee_limit,0) + 1))::int))
ELSE FLOOR(random() * 76)::int
END AS rsvp_count
FROM ev
)
INSERT INTO rsvps (event_id, name, user_id, created_at, updated_at)
SELECT
c.id AS event_id,
'Attendee ' || c.id || '-' || g AS name,
-- distribute user_ids across 200 synthetic users, deterministically mixed per event
'user_' || ((ABS(HASHTEXT(c.id)) + g) % 200 + 1)::text AS user_id,
NOW(), NOW()
FROM counts c
JOIN LATERAL generate_series(1, c.rsvp_count) AS g ON TRUE;
COMMIT;

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# Database
postgres:
@@ -10,7 +8,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-cactoide}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cactoide_password}
expose:
- '${POSTGRES_PORT:-5437}'
- '${POSTGRES_PORT:-5432}'
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
@@ -29,6 +29,7 @@ services:
# Application
app:
image: ghcr.io/polaroi8d/cactoide/cactoide:${APP_VERSION:-latest}
build: .
container_name: cactoide-app
ports:
- '${PORT:-5111}:3000'

View File

@@ -1,6 +0,0 @@
[build]
# Build command for SvelteKit
command = "npm run build"
# Publish directory (where the built files are located)
publish = "build"

33
package-lock.json generated
View File

@@ -10,13 +10,13 @@
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"postgres": "^3.4.7"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-netlify": "^5.2.2",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
@@ -1135,13 +1135,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iarna/toml": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
"dev": true,
"license": "ISC"
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1630,21 +1623,6 @@
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/adapter-netlify": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-netlify/-/adapter-netlify-5.2.2.tgz",
"integrity": "sha512-pFWB/lxG8NuJLEYOcPxD059v5QQDFX1vxpBbVobHjgJDCpSDLySGMi4ipDKNPfysqIA9TEG+rwdexz0iaIAocg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"esbuild": "^0.25.4",
"set-cookie-parser": "^2.6.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.4.0"
}
},
"node_modules/@sveltejs/adapter-node": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.3.1.tgz",
@@ -3242,6 +3220,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/get-tsconfig": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "event-cactus",
"name": "cactoide",
"private": true,
"version": "0.0.1",
"version": "0.0.3",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -40,6 +40,7 @@
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"postgres": "^3.4.7"
}
}

View File

@@ -2,14 +2,10 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
function navigateTo(path: string) {
goto(path);
}
// Check if current page is active
function isActive(path: string): boolean {
const isActive = (path: string): boolean => {
return $page.url.pathname === path;
}
};
</script>
<nav class="relative z-50 backdrop-blur-md">
@@ -18,7 +14,7 @@
<!-- Logo/Brand -->
<div class="flex items-center">
<button
on:click={() => navigateTo('/')}
on:click={() => goto('/')}
class="cursor-pointer text-2xl font-medium text-violet-400"
>
Cactoide
@@ -28,28 +24,28 @@
<!-- Navigation -->
<div class="md:flex md:items-center md:space-x-8">
<button
on:click={() => navigateTo('/')}
on:click={() => goto('/')}
class={isActive('/') ? 'text-violet-400' : 'cursor-pointer'}
>
Home
</button>
<button
on:click={() => navigateTo('/discover')}
on:click={() => goto('/discover')}
class={isActive('/discover') ? 'text-violet-400' : 'cursor-pointer'}
>
Discover
</button>
<button
on:click={() => navigateTo('/create')}
on:click={() => goto('/create')}
class={isActive('/create') ? 'text-violet-400' : 'cursor-pointer'}
>
Create
</button>
<button
on:click={() => navigateTo('/event')}
on:click={() => goto('/event')}
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}
>
My Events

View File

@@ -3,6 +3,13 @@ import { env } from '$env/dynamic/private';
import * as schema from './schema';
import postgres from 'postgres';
const client = postgres(env.DATABASE_URL, {});
// Database connection configuration
const connectionConfig = {
max: 10, // Maximum number of connections
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10 // Connection timeout in seconds
};
export const drizzleQuery = drizzle(client, { schema });
const client = postgres(env.DATABASE_URL, connectionConfig);
export const database = drizzle(client, { schema });

View File

@@ -1,12 +0,0 @@
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
};
export const formatTime = (timeString: string): string => {
const [hours, minutes] = timeString.split(':');
return `${hours}:${minutes}`;
};

40
src/lib/dateHelpers.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Event } from './types';
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
};
export const formatTime = (timeString: string): string => {
const [hours, minutes] = timeString.split(':');
return `${hours}:${minutes}`;
};
// Helper function to check if an event is within a time range
export const isEventInTimeRange = (event: Event, timeFilter: string): boolean => {
if (timeFilter === 'any') return true;
const eventDate = new Date(`${event.date}T${event.time}`);
const now = new Date();
// Handle temporal status filters
if (timeFilter === 'upcoming') {
return eventDate >= now;
}
if (timeFilter === 'past') {
return eventDate < now;
}
// Handle time range filters
const ranges: Record<string, Date> = {
'next-week': new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
'next-month': new Date(new Date(now).setMonth(now.getMonth() + 1))
};
const endDate = ranges[timeFilter];
return endDate ? eventDate >= now && eventDate <= endDate : true;
};

View File

@@ -13,18 +13,26 @@
<div class="flex min-h-screen flex-col">
<section class="mx-auto w-full pt-20 pb-20 md:w-3/4">
<div class="container mx-auto px-4 text-center">
<h1 class="text-5xl font-bold md:text-7xl lg:text-8xl">Cactoide(ea) 🌵</h1>
<h1 class="text-5xl font-bold md:text-7xl lg:text-8xl">
Cactoide(ea)<span class="text-violet-400"
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
> 🌵
</h1>
<h2 class="mt-6 text-xl md:text-2xl">The Ultimate RSVP Platform</h2>
<p class="mt-4 text-lg italic md:text-xl">
Create, share, and manage events with zero friction.
</p>
<h2 class="mt-6 pt-8 text-xl md:text-2xl">Why Cactoide(ae)?🌵</h2>
<h2 class="mt-6 pt-8 text-xl md:text-2xl">
Why Cactoide(ae)<span class="text-violet-400"
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
>?🌵
</h2>
<p class="mt-4 text-lg md:text-xl">
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae)
helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your
gatherings are resilient, vibrant, and unforgettabl e.
gatherings are resilient, vibrant, and unforgettable.
</p>
<button

View File

@@ -1,4 +1,4 @@
import { drizzleQuery } from '$lib/database/db';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
@@ -66,7 +66,7 @@ export const actions: Actions = {
const eventId = generateEventId();
await drizzleQuery
await database
.insert(events)
.values({
id: eventId,

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { CreateEventData, EventType } from '$lib/types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
export let form;
@@ -37,12 +38,16 @@
};
}
function handleTypeChange(type: EventType) {
const handleTypeChange = (type: EventType) => {
eventData.type = type;
if (type === 'unlimited') {
eventData.attendee_limit = undefined;
}
}
};
const handleCancel = () => {
goto(`/discover`);
};
</script>
<svelte:head>
@@ -248,33 +253,32 @@
</p>
</div>
<!-- Submit Button -->
<button
type="submit"
disabled={isSubmitting}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{#if isSubmitting}
<div class="flex items-center justify-center">
<div class="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
Creating Event...
</div>
{:else}
Create Event
{/if}
</button>
<div class="flex space-x-3">
<button
type="button"
on:click={handleCancel}
class="flex-1 rounded-sm border-2 border-slate-300 bg-slate-200 px-4 py-3 font-semibold text-slate-700 transition-all duration-200 hover:bg-slate-400 hover:text-slate-200"
>
Cancel
</button>
<!-- Submit Button -->
<button
type="submit"
disabled={isSubmitting}
class="hover:bg-violet-400/70'l rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{#if isSubmitting}
<div class="flex items-center justify-center">
<div class="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
Creating Event...
</div>
{:else}
Create Event
{/if}
</button>
</div>
</form>
</div>
<!-- Info Section -->
<div class="mt-8 p-6 text-center">
<p class="text-dark-100 font-medium">
Share the generated link with others to collect RSVPs.
</p>
<p class="mt-2 text-sm text-violet-300">
No registration required • Mobile optimized • Instant sharing
</p>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { drizzleQuery } from '$lib/database/db';
import { database } from '$lib/database/db';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { events } from '$lib/database/schema';
@@ -6,7 +6,7 @@ import { events } from '$lib/database/schema';
export const load: PageServerLoad = async () => {
try {
// Fetch all public events ordered by creation date (newest first)
const publicEvents = await drizzleQuery
const publicEvents = await database
.select()
.from(events)
.where(eq(events.visibility, 'public'))

View File

@@ -1,15 +1,83 @@
<script lang="ts">
import type { Event } from '$lib/types';
import type { Event, EventType } from '$lib/types';
import { goto } from '$app/navigation';
import type { PageData } from '../$types';
import { formatTime, formatDate } from '$lib/dateFormatter';
import { formatTime, formatDate, isEventInTimeRange } from '$lib/dateHelpers';
import Fuse from 'fuse.js';
let publicEvents: Event[] = [];
let error = '';
let searchQuery = '';
let selectedEventType: EventType | 'all' = 'all';
let selectedTimeFilter: 'any' | 'next-week' | 'next-month' = 'any';
let selectedTemporalStatus: 'all' | 'upcoming' | 'past' = 'all';
let selectedSortOrder: 'asc' | 'desc' = 'asc';
let showFilters = false;
let fuse: Fuse<Event>;
export let data: PageData;
// Use the server-side data
$: publicEvents = data.events;
// Initialize Fuse.js with search options
$: fuse = new Fuse(publicEvents, {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'location', weight: 0.3 }
],
threshold: 0.3, // Lower threshold = more strict matching
includeScore: true,
includeMatches: true
});
// Filter events based on search query, event type, time filter, and temporal status
$: filteredEvents = (() => {
let events = publicEvents;
// First filter by event type
if (selectedEventType !== 'all') {
events = events.filter((event) => event.type === selectedEventType);
}
// Then filter by temporal status (past/upcoming/all)
if (selectedTemporalStatus !== 'all') {
events = events.filter((event) => isEventInTimeRange(event, selectedTemporalStatus));
}
// Then filter by time range
if (selectedTimeFilter !== 'any') {
events = events.filter((event) => isEventInTimeRange(event, selectedTimeFilter));
}
// Then apply search query
if (searchQuery.trim() !== '') {
events = fuse.search(searchQuery).map((result) => result.item);
// Re-apply all filters after search
if (selectedEventType !== 'all') {
events = events.filter((event) => event.type === selectedEventType);
}
if (selectedTemporalStatus !== 'all') {
events = events.filter((event) => isEventInTimeRange(event, selectedTemporalStatus));
}
if (selectedTimeFilter !== 'any') {
events = events.filter((event) => isEventInTimeRange(event, selectedTimeFilter));
}
}
// Sort events by date and time
events = events.sort((a, b) => {
const dateA = new Date(`${a.date}T${a.time}`);
const dateB = new Date(`${b.date}T${b.time}`);
if (selectedSortOrder === 'asc') {
return dateA.getTime() - dateB.getTime();
} else {
return dateB.getTime() - dateA.getTime();
}
});
return events;
})();
</script>
<svelte:head>
@@ -48,12 +116,140 @@
{:else}
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-300">Public Events ({publicEvents.length})</h2>
<h2 class="text-2xl font-bold text-slate-300">Public Events ({filteredEvents.length})</h2>
<p class="text-slate-500">Discover events created by the community</p>
</div>
<!-- Search and Filter Section -->
<div class="mb-8 max-h-screen">
<!-- Search Bar and Filter Toggle -->
<div class="mx-auto flex w-full items-center gap-3 md:w-2/3">
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
class="h-5 w-5 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<input
type="text"
bind:value={searchQuery}
placeholder="Search events by name, location..."
class="w-full rounded-sm border border-slate-600 bg-slate-800 pl-10 text-white placeholder-slate-400 focus:border-violet-500 focus:ring-violet-500/20"
/>
{#if searchQuery}
<button
on:click={() => (searchQuery = '')}
class="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-400 hover:text-slate-300"
aria-label="Search input"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
{/if}
</div>
<!-- Filter Toggle Button -->
<button
on:click={() => (showFilters = !showFilters)}
class="flex items-center rounded-sm border p-3 font-semibold {showFilters
? 'border-violet-500 bg-violet-400/20'
: 'border-slate-600 bg-slate-800'}"
aria-label="Toggle filters"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.207A1 1 0 013 6.5V4z"
></path>
</svg>
</button>
</div>
<!-- Time Filter and Sort Controls -->
{#if showFilters}
<div
class="mx-auto mt-4 flex flex-col items-center gap-4 sm:flex-row sm:justify-center"
>
<!-- Event Type Filter -->
<div class="flex items-center gap-2">
<label for="event-type-filter" class="text-sm font-medium text-slate-400"
>Type:</label
>
<select
id="event-type-filter"
bind:value={selectedEventType}
class="rounded-sm border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
<option value="all">All</option>
<option value="limited">Limited</option>
<option value="unlimited">Unlimited</option>
</select>
</div>
<!-- Temporal Status Filter -->
<div class="flex items-center gap-2">
<label for="temporal-status-filter" class="text-sm font-medium text-slate-400"
>Status:</label
>
<select
id="temporal-status-filter"
bind:value={selectedTemporalStatus}
class="rounded-sm border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
<option value="all">All events</option>
<option value="upcoming">Upcoming events</option>
<option value="past">Past events</option>
</select>
</div>
<!-- Time Filter Dropdown -->
<div class="flex items-center gap-2">
<label for="time-filter" class="text-sm font-medium text-slate-400">Time:</label>
<select
id="time-filter"
bind:value={selectedTimeFilter}
class="rounded-sm border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
<option value="any">Any time</option>
<option value="next-week">Next week</option>
<option value="next-month">Next month</option>
</select>
</div>
<!-- Sort Order Dropdown -->
<div class="flex items-center gap-2">
<label for="sort-order" class="text-sm font-medium text-slate-400">Sort:</label>
<select
id="sort-order"
bind:value={selectedSortOrder}
class="rounded-sm border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
<option value="asc">Earliest first</option>
<option value="desc">Latest first</option>
</select>
</div>
</div>
{/if}
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each publicEvents as event, i (i)}
{#each filteredEvents as event, i (i)}
<div class="rounded-sm border border-slate-200 p-6 shadow-sm">
<div class="mb-4">
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
@@ -99,17 +295,25 @@
</div>
</div>
<div class="flex space-x-3">
<div class="flex">
<button
on:click={() => goto(`/event/${event.id}`)}
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
>
View Event
View
</button>
</div>
</div>
{/each}
</div>
{#if searchQuery && filteredEvents.length === 0}
<div class="mt-8 text-center">
<div class="mb-4 text-4xl">🔍</div>
<h3 class="mb-2 text-xl font-bold text-slate-300">No events found</h3>
<p class="text-slate-500">Try adjusting your search terms or browse all events</p>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,7 +1,8 @@
import { drizzleQuery } from '$lib/database/db';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { fail } from '@sveltejs/kit';
import { eq, desc } from 'drizzle-orm';
import type { Actions } from './$types';
export const load = async ({ cookies }) => {
const userId = cookies.get('cactoideUserId');
@@ -11,7 +12,7 @@ export const load = async ({ cookies }) => {
}
try {
const userEvents = await drizzleQuery
const userEvents = await database
.select()
.from(events)
.where(eq(events.userId, userId))
@@ -50,7 +51,7 @@ export const actions: Actions = {
try {
// First verify the user owns this event
const [eventData] = await drizzleQuery.select().from(events).where(eq(events.id, eventId));
const [eventData] = await database.select().from(events).where(eq(events.id, eventId));
if (!eventData) {
return fail(404, { error: 'Event not found' });
@@ -61,7 +62,7 @@ export const actions: Actions = {
}
// Delete the event (RSVPs will be deleted automatically due to CASCADE)
await drizzleQuery.delete(events).where(eq(events.id, eventId));
await database.delete(events).where(eq(events.id, eventId));
return { success: true };
} catch (error) {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Event } from '$lib/types';
import { goto } from '$app/navigation';
import { formatTime, formatDate } from '$lib/dateFormatter';
import { formatTime, formatDate } from '$lib/dateHelpers';
export let data: { events: Event[] };
@@ -60,7 +60,7 @@
</script>
<svelte:head>
<title>My Events - Event Cactus</title>
<title>My Events - Cactoide</title>
</svelte:head>
<div class="flex min-h-screen flex-col">
@@ -149,15 +149,65 @@
<button
on:click={() => goto(`/event/${event.id}`)}
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
aria-label="View event"
>
View
<svg
class="mx-auto h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
</svg>
</button>
<button
on:click={() => goto(`/event/${event.id}/edit`)}
class="flex-1 rounded-sm border-2 border-blue-400 bg-blue-400/20 px-4 py-2 font-semibold text-white duration-200 hover:bg-blue-400/70"
aria-label="Edit event"
>
<svg
class="mx-auto h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
></path>
</svg>
</button>
<button
on:click={() => openDeleteModal(event)}
class="flex-1 rounded-sm border-2 border-red-400 bg-red-400/20 px-4 py-2 font-semibold text-white duration-200 hover:bg-red-400/70"
aria-label="Delete event"
>
Delete
<svg
class="mx-auto h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { drizzleQuery } from '$lib/database/db';
import { database } from '$lib/database/db';
import { events, rsvps } from '$lib/database/schema';
import { eq, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
@@ -14,12 +14,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
try {
// Fetch event and RSVPs in parallel
const [eventData, rsvpData] = await Promise.all([
drizzleQuery.select().from(events).where(eq(events.id, eventId)).limit(1),
drizzleQuery
.select()
.from(rsvps)
.where(eq(rsvps.eventId, eventId))
.orderBy(asc(rsvps.createdAt))
database.select().from(events).where(eq(events.id, eventId)).limit(1),
database.select().from(rsvps).where(eq(rsvps.eventId, eventId)).orderBy(asc(rsvps.createdAt))
]);
if (!eventData[0]) {
@@ -81,33 +77,27 @@ export const actions: Actions = {
try {
// Check if event exists and get its details
const [eventData] = await drizzleQuery.select().from(events).where(eq(events.id, eventId));
const [eventData] = await database.select().from(events).where(eq(events.id, eventId));
if (!eventData) {
return fail(404, { error: 'Event not found' });
}
// Check if event is full (for limited type events)
if (eventData.type === 'limited' && eventData.attendeeLimit) {
const currentRSVPs = await drizzleQuery
.select()
.from(rsvps)
.where(eq(rsvps.eventId, eventId));
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
if (currentRSVPs.length >= eventData.attendeeLimit) {
return fail(400, { error: 'Event is full' });
}
}
// Check if name is already in the list
const existingRSVPs = await drizzleQuery
.select()
.from(rsvps)
.where(eq(rsvps.eventId, eventId));
const existingRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
if (existingRSVPs.some((rsvp) => rsvp.name.toLowerCase() === name.toLowerCase())) {
return fail(400, { error: 'Name already exists for this event' });
}
// Add RSVP to database
await drizzleQuery.insert(rsvps).values({
await database.insert(rsvps).values({
eventId: eventId,
name: name.trim(),
userId: userId,
@@ -131,7 +121,7 @@ export const actions: Actions = {
}
try {
await drizzleQuery.delete(rsvps).where(eq(rsvps.id, rsvpId));
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
return { success: true, type: 'remove' };
} catch (err) {
console.error('Error removing RSVP:', err);

View File

@@ -3,7 +3,7 @@
import type { Event, RSVP } from '$lib/types';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { formatTime, formatDate } from '$lib/dateFormatter';
import { formatTime, formatDate } from '$lib/dateHelpers.js';
export let data: { event: Event; rsvps: RSVP[]; userId: string };
export let form;
@@ -35,7 +35,7 @@
const eventId = $page.params.id;
function copyEventLink() {
const copyEventLink = () => {
const url = `${window.location.origin}/event/${eventId}`;
navigator.clipboard.writeText(url).then(() => {
success = 'Event link copied to clipboard!';
@@ -43,12 +43,12 @@
success = '';
}, 3000);
});
}
};
function clearMessages() {
const clearMessages = () => {
error = '';
success = '';
}
};
</script>
<svelte:head>
@@ -300,7 +300,7 @@
</div>
{/if}
</div>
succcess: {success}
<!-- Action Buttons -->
<div class="max-w-2xl">
<button

View File

@@ -0,0 +1,123 @@
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { eq, and } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const eventId = params.id;
const userId = cookies.get('cactoideUserId');
if (!userId) {
throw redirect(303, '/');
}
// Fetch the event and verify ownership
const event = await database
.select()
.from(events)
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
.limit(1);
if (event.length === 0) {
throw redirect(303, '/event');
}
return {
event: event[0]
};
};
export const actions: Actions = {
default: async ({ request, params, cookies }) => {
const eventId = params.id;
const userId = cookies.get('cactoideUserId');
const formData = await request.formData();
if (!userId) {
return fail(401, { error: 'Unauthorized' });
}
// Verify event ownership before allowing edit
const existingEvent = await database
.select()
.from(events)
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
.limit(1);
if (existingEvent.length === 0) {
return fail(403, { error: 'You can only edit your own events' });
}
const name = formData.get('name') as string;
const date = formData.get('date') as string;
const time = formData.get('time') as string;
const location = formData.get('location') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private';
// Validation
const missingFields: string[] = [];
if (!name?.trim()) missingFields.push('name');
if (!date) missingFields.push('date');
if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location');
if (missingFields.length > 0) {
return fail(400, {
error: `Missing or empty fields: ${missingFields.join(', ')}`,
values: {
name,
date,
time,
location,
type,
attendee_limit: attendeeLimit,
visibility
}
});
}
// Check if date is in the past (but allow editing past events for corrections)
const eventDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (eventDate < today) {
return fail(400, {
error: 'Date cannot be in the past.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
});
}
if (type === 'limited' && (!attendeeLimit || parseInt(attendeeLimit) < 2)) {
return fail(400, {
error: 'Limit must be at least 2 for limited events.',
values: { name, date, time, location, type, attendee_limit: attendeeLimit, visibility }
});
}
// Update the event
await database
.update(events)
.set({
name: name.trim(),
date: date,
time: time,
location: location.trim(),
type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility,
updatedAt: new Date()
})
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
.catch((error) => {
console.error('Unexpected error updating event', error);
throw error;
});
throw redirect(303, `/event/${eventId}`);
}
};

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import type { EventType } from '$lib/types';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
export let data;
export let form;
let eventData = {
name: data.event.name,
date: data.event.date,
time: data.event.time,
location: data.event.location,
type: data.event.type,
attendee_limit: data.event.attendeeLimit,
visibility: data.event.visibility
};
let errors: Record<string, string> = {};
let isSubmitting = false;
// Get today's date in YYYY-MM-DD format for min attribute
const today = new Date().toISOString().split('T')[0];
// Handle form errors from server
$: if (form?.error) {
errors.server = form.error;
}
// Pre-fill form with values from server on error
$: if (form && 'values' in form && form.values) {
const values = form.values;
eventData = {
...eventData,
...values,
attendee_limit: values.attendee_limit ? parseInt(String(values.attendee_limit)) : null
};
}
const handleTypeChange = (type: EventType) => {
eventData.type = type;
if (type === 'unlimited') {
eventData.attendee_limit = null;
}
};
const handleCancel = () => {
goto(`/event/${data.event.id}`);
};
</script>
<svelte:head>
<title>Edit Event - {data.event.name} - Cactoide</title>
</svelte:head>
<div class="flex min-h-screen flex-col">
<!-- Main Content -->
<div class="container mx-auto flex-1 px-4 py-8">
<div class="mx-auto max-w-md">
<!-- Event Edit Form -->
<div class="rounded-sm border p-8">
<div class="mb-8 text-center">
<h2 class="text-3xl font-bold text-violet-400">Edit Event</h2>
<p class="mt-2 text-sm text-slate-400">Update your event details</p>
</div>
<form
method="POST"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
isSubmitting = false;
if (result.type === 'failure') {
// Handle validation errors
if (result.data?.error) {
errors.server = String(result.data.error);
}
}
update();
};
}}
class="space-y-6"
>
<input type="hidden" name="type" value={eventData.type} />
<input type="hidden" name="visibility" value={eventData.visibility} />
{#if errors.server}
<div class="mb-6 rounded-sm border border-red-200 bg-red-50 p-4 text-red-700">
{errors.server}
</div>
{/if}
<!-- Event Name -->
<div>
<label for="name" class="text-dark-800 mb-3 block text-sm font-semibold">
Name <span class="text-red-400">*</span>
</label>
<input
id="name"
name="name"
type="text"
bind:value={eventData.name}
class="border-dark-300 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm"
placeholder="Enter event name"
maxlength="100"
required
/>
{#if errors.name}
<p class="mt-2 text-sm font-medium text-red-600">{errors.name}</p>
{/if}
</div>
<!-- Date and Time Row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label for="date" class="text-dark-800 mb-3 block text-sm font-semibold">
Date <span class="text-red-400">*</span>
</label>
<input
id="date"
name="date"
type="date"
bind:value={eventData.date}
min={today}
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
required
/>
{#if errors.date}
<p class="mt-2 text-sm font-medium text-red-600">{errors.date}</p>
{/if}
</div>
<div>
<label for="time" class="text-dark-800 mb-3 block text-sm font-semibold">
Time <span class="text-red-400">*</span>
</label>
<input
id="time"
name="time"
type="time"
bind:value={eventData.time}
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
required
/>
{#if errors.time}
<p class="mt-2 text-sm font-medium text-red-600">{errors.time}</p>
{/if}
</div>
</div>
<!-- Location -->
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
Location <span class="text-red-400">*</span>
</label>
<input
id="location"
name="location"
type="text"
bind:value={eventData.location}
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="Enter location"
maxlength="200"
required
/>
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
</div>
<!-- Event Type -->
<div>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
Type <span class="text-red-400">*</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.type ===
'unlimited'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleTypeChange('unlimited')}
>
Unlimited
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.type ===
'limited'
? ' 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={() => handleTypeChange('limited')}
>
Limited
</button>
</div>
</fieldset>
</div>
<!-- Limit (only for limited events) -->
{#if eventData.type === 'limited'}
<div>
<label for="limit" class="text-dark-800 mb-3 block text-sm font-semibold">
Attendee Limit *
</label>
<input
id="attendee_limit"
name="attendee_limit"
type="number"
bind:value={eventData.attendee_limit}
min="1"
max="1000"
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
placeholder="Enter limit"
required
/>
{#if errors.attendee_limit}
<p class="mt-2 text-sm font-medium text-red-600">{errors.attendee_limit}</p>
{/if}
</div>
{/if}
<!-- Event Visibility -->
<div>
<fieldset>
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
Visibility <span class="text-red-400">*</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.visibility ===
'public'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => (eventData.visibility = 'public')}
>
🌍 Public
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'private'
? ' 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={() => (eventData.visibility = 'private')}
>
🔒 Private
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.visibility === 'public'
? 'Public events are visible to everyone and can be discovered by others'
: 'Private events are only visible to you and people you share the link with'}
</p>
</fieldset>
</div>
<!-- Action Buttons -->
<div class="flex space-x-3">
<button
type="button"
on:click={handleCancel}
class="flex-1 rounded-sm border-2 border-slate-300 bg-slate-200 px-4 py-3 font-semibold text-slate-700 transition-all duration-200 hover:bg-slate-400 hover:text-slate-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
class="hover:bg-violet-400/70' flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{#if isSubmitting}
<div class="flex items-center justify-center">
<div class="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"></div>
Updating...
</div>
{:else}
Update Event
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,16 @@
// src/routes/healthz/+server.ts
import { json } from '@sveltejs/kit';
import { database } from '$lib/database/db';
import { sql } from 'drizzle-orm';
export async function GET() {
try {
await database.execute(sql`select 1`);
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
} catch (err) {
return json(
{ ok: false, error: (err as Error)?.message, message: 'Database unreachable.' },
{ status: 503, headers: { 'cache-control': 'no-store' } }
);
}
}

View File

@@ -8,7 +8,6 @@ const config = {
preprocess: vitePreprocess(),
kit: {
// Using Netlify adapter for deployment
adapter: adapter({
// if you want to use 'split' mode, set this to 'split'
// and create a _redirects file with the redirects you want

View File

@@ -4,9 +4,7 @@ export default {
theme: {
extend: {
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
display: ['Inter', 'system-ui', 'sans-serif'],
sans: ['Inter', 'system-ui', 'sans-serif']
mono: ['JetBrains Mono', 'Fira Code', 'monospace']
}
}
}