2
0
forked from jmug/cactoide

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_PORT=5432
# localhost
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoied_database"
# docker
# DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoied_database"
# Application configuration
APP_VERSION=latest

View File

@@ -1,3 +1,4 @@
# .github/workflows/docker-build-and-push.yml
name: build & push the images
on:
@@ -38,8 +39,24 @@ jobs:
username: ${{ github.actor }}
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
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
@@ -47,13 +64,5 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:latest
${{ 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"
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

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
container_name: cactoide-db
environment:
POSTGRES_DB: ${POSTGRES_DB:-cactoied_database}
POSTGRES_DB: ${POSTGRES_DB:-cactoide_database}
POSTGRES_USER: ${POSTGRES_USER:-cactoide}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cactoide_password}
ports:
- '${POSTGRES_PORT:-5432}:5432'
expose:
- '${POSTGRES_PORT:-5437}'
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
@@ -18,7 +18,7 @@ services:
test:
[
'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
timeout: 5s
@@ -31,9 +31,9 @@ services:
image: ghcr.io/polaroi8d/cactoide/cactoide:${APP_VERSION:-latest}
container_name: cactoide-app
ports:
- '${PORT:-3000}:3000'
- '${PORT:-5111}:3000'
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
HOSTNAME: ${HOSTNAME:-0.0.0.0}
depends_on:

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

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

@@ -9,11 +9,11 @@ export function load({ cookies }) {
const PATH = '/';
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 });
} else {
console.log(`cactoideUserId: ${cactoideUserId}`);
console.log(`cactoideUserId cookie found, using existing one...`);
console.debug(`cactoideUserId: ${cactoideUserId}`);
console.debug(`cactoideUserId cookie found, using existing one...`);
}
return {

View File

@@ -13,14 +13,22 @@
<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

View File

@@ -1,15 +1,51 @@
<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 Fuse from 'fuse.js';
let publicEvents: Event[] = [];
let error = '';
let searchQuery = '';
let selectedEventType: EventType | 'all' = 'all';
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 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>
<svelte:head>
@@ -48,12 +84,88 @@
{: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">
<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">
{#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>
@@ -110,6 +222,14 @@
</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

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

View File

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