Compare commits

...

7 Commits

Author SHA1 Message Date
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
Levente Orban
fefca207c5 feat: remove or refactor unused console.logs 2025-08-27 22:39:42 +02:00
Levente Orban
e89c9b1843 feat: add docker DATABASE_URL 2025-08-27 22:35:21 +02:00
Levente Orban
4b14f649d6 fix: docker-compose renames and fixes 2025-08-27 22:34:06 +02:00
Levente Orban
3cbdd93386 ci: testing the tags pipeline 2025-08-27 17:13:47 +02:00
11 changed files with 254 additions and 54 deletions

View File

@@ -4,7 +4,10 @@ POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# localhost
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoied_database" DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoied_database"
# docker
# DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoied_database"
# Application configuration # Application configuration
APP_VERSION=latest APP_VERSION=latest

View File

@@ -1,3 +1,4 @@
# .github/workflows/docker-build-and-push.yml
name: build & push the images name: build & push the images
on: on:
@@ -38,8 +39,24 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Generate tags & labels:
# - short SHA on all pushes
# - git tag on tag pushes
# - latest only on default branch (e.g., main)
- name: Extract Docker metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}
tags: |
type=sha,format=short
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -47,13 +64,5 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest labels: ${{ steps.meta.outputs.labels }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:${{ github.ref_type == 'tag' && github.ref_name || '' }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- name: Output image info
run: |
echo "Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest"

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

@@ -6,11 +6,11 @@ services:
image: postgres:15-alpine image: postgres:15-alpine
container_name: cactoide-db container_name: cactoide-db
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-cactoied_database} POSTGRES_DB: ${POSTGRES_DB:-cactoide_database}
POSTGRES_USER: ${POSTGRES_USER:-cactoide} POSTGRES_USER: ${POSTGRES_USER:-cactoide}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cactoide_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cactoide_password}
ports: expose:
- '${POSTGRES_PORT:-5432}:5432' - '${POSTGRES_PORT:-5437}'
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
@@ -18,7 +18,7 @@ services:
test: test:
[ [
'CMD-SHELL', 'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-cactoide} -d ${POSTGRES_DB:-cactoied_database}' 'pg_isready -U ${POSTGRES_USER:-cactoide} -d ${POSTGRES_DB:-cactoide_database}'
] ]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
@@ -31,9 +31,9 @@ services:
image: ghcr.io/polaroi8d/cactoide/cactoide:${APP_VERSION:-latest} image: ghcr.io/polaroi8d/cactoide/cactoide:${APP_VERSION:-latest}
container_name: cactoide-app container_name: cactoide-app
ports: ports:
- '${PORT:-3000}:3000' - '${PORT:-5111}:3000'
environment: environment:
DATABASE_URL: postgres://${POSTGRES_USER:-cactoide}:${POSTGRES_PASSWORD:-cactoide_password}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-cactoied_database} DATABASE_URL: ${DATABASE_URL}
PORT: 3000 PORT: 3000
HOSTNAME: ${HOSTNAME:-0.0.0.0} HOSTNAME: ${HOSTNAME:-0.0.0.0}
depends_on: depends_on:

33
package-lock.json generated
View File

@@ -10,13 +10,13 @@
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.3.1", "@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"postgres": "^3.4.7" "postgres": "^3.4.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-netlify": "^5.2.2",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
@@ -1135,13 +1135,6 @@
"url": "https://github.com/sponsors/nzakas" "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": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1630,21 +1623,6 @@
"@sveltejs/kit": "^2.0.0" "@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": { "node_modules/@sveltejs/adapter-node": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.3.1.tgz",
@@ -3242,6 +3220,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-tsconfig": {
"version": "4.10.1", "version": "4.10.1",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",

View File

@@ -40,6 +40,7 @@
"dependencies": { "dependencies": {
"@sveltejs/adapter-node": "^5.3.1", "@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"postgres": "^3.4.7" "postgres": "^3.4.7"
} }
} }

View File

@@ -9,11 +9,11 @@ export function load({ cookies }) {
const PATH = '/'; const PATH = '/';
if (!cactoideUserId) { if (!cactoideUserId) {
console.log(`There is no cactoideUserId cookie, generating new one...`); console.debug(`There is no cactoideUserId cookie, generating new one...`);
cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE }); cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
} else { } else {
console.log(`cactoideUserId: ${cactoideUserId}`); console.debug(`cactoideUserId: ${cactoideUserId}`);
console.log(`cactoideUserId cookie found, using existing one...`); console.debug(`cactoideUserId cookie found, using existing one...`);
} }
return { return {

View File

@@ -13,14 +13,22 @@
<div class="flex min-h-screen flex-col"> <div class="flex min-h-screen flex-col">
<section class="mx-auto w-full pt-20 pb-20 md:w-3/4"> <section class="mx-auto w-full pt-20 pb-20 md:w-3/4">
<div class="container mx-auto px-4 text-center"> <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> <h2 class="mt-6 text-xl md:text-2xl">The Ultimate RSVP Platform</h2>
<p class="mt-4 text-lg italic md:text-xl"> <p class="mt-4 text-lg italic md:text-xl">
Create, share, and manage events with zero friction. Create, share, and manage events with zero friction.
</p> </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"> <p class="mt-4 text-lg md:text-xl">
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) 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 helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your

View File

@@ -1,15 +1,51 @@
<script lang="ts"> <script lang="ts">
import type { Event } from '$lib/types'; import type { Event, EventType } from '$lib/types';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { PageData } from '../$types'; import type { PageData } from '../$types';
import { formatTime, formatDate } from '$lib/dateFormatter'; import { formatTime, formatDate } from '$lib/dateFormatter';
import Fuse from 'fuse.js';
let publicEvents: Event[] = []; let publicEvents: Event[] = [];
let error = ''; let error = '';
let searchQuery = '';
let selectedEventType: EventType | 'all' = 'all';
let fuse: Fuse<Event>;
export let data: PageData; export let data: PageData;
// Use the server-side data // Use the server-side data
$: publicEvents = data.events; $: 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 and event type using Fuse.js
$: filteredEvents = (() => {
let events = publicEvents;
// First filter by event type
if (selectedEventType !== 'all') {
events = events.filter((event) => event.type === selectedEventType);
}
// Then apply search query
if (searchQuery.trim() !== '') {
events = fuse.search(searchQuery).map((result) => result.item);
// Re-apply type filter after search
if (selectedEventType !== 'all') {
events = events.filter((event) => event.type === selectedEventType);
}
}
return events;
})();
</script> </script>
<svelte:head> <svelte:head>
@@ -48,12 +84,88 @@
{:else} {:else}
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<div class="mb-6"> <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> <p class="text-slate-500">Discover events created by the community</p>
</div> </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"
>
<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"
>
<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>
<!-- 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'}"
>
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>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <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="rounded-sm border border-slate-200 p-6 shadow-sm">
<div class="mb-4"> <div class="mb-4">
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3> <h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
@@ -110,6 +222,14 @@
</div> </div>
{/each} {/each}
</div> </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> </div>
{/if} {/if}
</div> </div>

View File

@@ -17,8 +17,6 @@ export const load = async ({ cookies }) => {
.where(eq(events.userId, userId)) .where(eq(events.userId, userId))
.orderBy(desc(events.createdAt)); .orderBy(desc(events.createdAt));
console.log(userEvents);
const transformedEvents = userEvents.map((event) => ({ const transformedEvents = userEvents.map((event) => ({
id: event.id, id: event.id,
name: event.name, name: event.name,

View File

@@ -75,9 +75,6 @@ export const actions: Actions = {
const name = formData.get('newAttendeeName') as string; const name = formData.get('newAttendeeName') as string;
const userId = cookies.get('cactoideUserId'); const userId = cookies.get('cactoideUserId');
console.log(`name: ${name}`);
console.log(`userId: ${userId}`);
if (!name?.trim() || !userId) { if (!name?.trim() || !userId) {
return fail(400, { error: 'Name and user ID are required' }); return fail(400, { error: 'Name and user ID are required' });
} }