Compare commits

...

8 Commits

Author SHA1 Message Date
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
17 changed files with 205 additions and 123 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

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"

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",

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,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,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

@@ -9,6 +9,8 @@
let error = '';
let searchQuery = '';
let selectedEventType: EventType | 'all' = 'all';
let selectedTimeFilter: 'any' | 'next-week' | 'next-month' = 'any';
let selectedSortOrder: 'asc' | 'desc' = 'asc';
let fuse: Fuse<Event>;
export let data: PageData;
@@ -26,7 +28,29 @@
includeMatches: true
});
// Filter events based on search query and event type using Fuse.js
// Helper function to check if an event is within a time range
function isEventInTimeRange(event: Event, timeFilter: string): boolean {
if (timeFilter === 'any') return true;
const eventDate = new Date(`${event.date}T${event.time}`);
const now = new Date();
if (timeFilter === 'next-week') {
const nextWeek = new Date(now);
nextWeek.setDate(now.getDate() + 7);
return eventDate >= now && eventDate <= nextWeek;
}
if (timeFilter === 'next-month') {
const nextMonth = new Date(now);
nextMonth.setMonth(now.getMonth() + 1);
return eventDate >= now && eventDate <= nextMonth;
}
return true;
}
// Filter events based on search query, event type, and time filter using Fuse.js
$: filteredEvents = (() => {
let events = publicEvents;
@@ -35,15 +59,35 @@
events = events.filter((event) => event.type === selectedEventType);
}
// 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 type filter after search
// Re-apply type and time filters after search
if (selectedEventType !== 'all') {
events = events.filter((event) => event.type === selectedEventType);
}
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>
@@ -89,77 +133,89 @@
</div>
<!-- Search and Filter Section -->
<div class="mb-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-center">
<!-- Search Bar -->
<div class="relative mx-auto max-w-md sm:mx-0">
<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"
>
<div class="mb-8 max-h-screen">
<!-- Search Bar -->
<div class="relative mx-auto w-full md:w-2/3">
<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-lg border border-slate-600 bg-slate-800 px-4 py-3 pl-10 text-white placeholder-slate-400 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20 focus:outline-none"
/>
{#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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<input
type="text"
bind:value={searchQuery}
placeholder="Search events by name, location..."
class="w-full rounded-lg border border-slate-600 bg-slate-800 px-4 py-3 pl-10 text-white placeholder-slate-400 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20 focus:outline-none"
/>
{#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"
>
<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}
</button>
{/if}
</div>
<!-- Time Filter and Sort Controls -->
<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-lg 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 focus:outline-none"
>
<option value="all">All</option>
<option value="limited">Limited</option>
<option value="unlimited">Unlimited</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-lg 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 focus:outline-none"
>
<option value="any">Any time</option>
<option value="next-week">Next week</option>
<option value="next-month">Next month</option>
</select>
</div>
<!-- Event Type Filter -->
<div class="flex items-center justify-center gap-2">
<button
on:click={() => (selectedEventType = 'all')}
class="rounded-sm border px-3 py-2 text-sm font-medium transition-colors {selectedEventType ===
'all'
? 'border-violet-500 bg-violet-500/20 text-violet-400'
: 'border-slate-600 text-slate-400 hover:border-slate-500 hover:text-slate-300'}"
<!-- 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-lg 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 focus:outline-none"
>
All
</button>
<button
on:click={() => (selectedEventType = 'limited')}
class="rounded-sm border px-3 py-2 text-sm font-medium transition-colors {selectedEventType ===
'limited'
? 'border-amber-600 bg-amber-600/20 text-amber-600'
: 'border-slate-600 text-slate-400 hover:border-slate-500 hover:text-slate-300'}"
>
Limited
</button>
<button
on:click={() => (selectedEventType = 'unlimited')}
class="rounded-sm border px-3 py-2 text-sm font-medium transition-colors {selectedEventType ===
'unlimited'
? 'border-teal-500 bg-teal-500/20 text-teal-500'
: 'border-slate-600 text-slate-400 hover:border-slate-500 hover:text-slate-300'}"
>
Unlimited
</button>
<option value="asc">Earliest first</option>
<option value="desc">Latest first</option>
</select>
</div>
</div>
</div>

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 } from '@sveltejs/kit';
import { eq, desc } from 'drizzle-orm';
@@ -11,7 +11,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 +50,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 +61,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

@@ -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">

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

@@ -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']
}
}
}