2
0
forked from jmug/cactoide

Compare commits

..

33 Commits

Author SHA1 Message Date
Levente Orban
eb5543fb9b feat: fail fast if db not connecting 2025-10-23 09:47:27 +02:00
Levente Orban
706e6cf712 chore(deps-dev): bump vite from 7.1.10 to 7.1.11 in the npm_and_yarn group across 1 directory 2025-10-21 10:12:34 +02:00
dependabot[bot]
c6decaa0d1 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.10 to 7.1.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 00:22:12 +00:00
Levente Orban
d193283b11 fix: creating an event and showing the wrong date 2025-10-20 21:22:30 +02:00
Levente Orban
accfd540f0 fix: creating an event and showing the wrong date 2025-10-20 11:43:34 +02:00
Levente Orban
9b1ef64618 fix: creating an event and showing the wrong date 2025-10-20 11:25:35 +02:00
Levente Orban
c340088434 fix: userId not generated in the first visit 2025-10-20 10:32:33 +02:00
Levente Orban
984c296725 fix: userId not generated in the first visit 2025-10-20 10:18:20 +02:00
Levente Orban
9acfa08ea8 chore(deps-dev): bump vite from 7.1.2 to 7.1.10 in the npm_and_yarn group across 1 directory 2025-10-20 08:49:24 +02:00
Levente Orban
45cb95f6a8 chore(deps): bump devalue from 5.1.1 to 5.4.1 in the npm_and_yarn group across 1 directory 2025-10-20 08:49:13 +02:00
dependabot[bot]
8426bd5704 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.2 to 7.1.10
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.10
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-19 07:00:04 +00:00
dependabot[bot]
b9833db3bb chore(deps): bump devalue in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [devalue](https://github.com/sveltejs/devalue).


Updates `devalue` from 5.1.1 to 5.4.1
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.1.1...v5.4.1)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.4.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-19 07:00:03 +00:00
Levente Orban
b3572293ba chore: add support me section to README.md 2025-10-15 15:54:42 +02:00
Levente Orban
c1752efe4b chore: add support me section to README.md 2025-10-15 10:44:41 +02:00
Levente Orban
491d0020bd fix: remove dev omit 2025-10-10 21:58:23 +02:00
Levente Orban
5c1182dc66 fix: remove dev omit 2025-10-10 21:57:17 +02:00
Levente Orban
638b5ff1ca feat: add an option to remove the landing page 2025-10-10 21:37:50 +02:00
Levente Orban
069ca11917 feat: add an option to remove the landing page 2025-10-10 21:32:59 +02:00
Levente Orban
4675fa4623 feat: add an option to remove the landing page 2025-10-10 21:29:18 +02:00
Levente Orban
af88d6462b feat: add an option to remove the landing page 2025-10-10 18:32:28 +02:00
Levente Orban
ef6005e648 feat: add an option to remove the landing page 2025-10-10 18:27:58 +02:00
Levente Orban
11875b4a1e feat: add an option to remove the landing page 2025-10-10 18:11:18 +02:00
Levente Orban
22038f7779 feat: add an option to remove the landing page 2025-10-10 10:42:46 +02:00
Levente Orban
de2cb07a15 feat: add an option to remove the landing page 2025-10-10 10:37:52 +02:00
Levente Orban
ffc29b9c24 feat: add an option to remove the landing page 2025-10-10 10:29:32 +02:00
Levente Orban
4860b9439c feat: add an option to remove the landing page 2025-10-10 10:26:56 +02:00
Levente Orban
d10af13134 feat: add an option to remove the landing page 2025-10-10 10:17:06 +02:00
Levente Orban
c98260efec feat: add an option to remove the landing page 2025-10-10 10:06:34 +02:00
Levente Orban
a40b83c2b3 feat: i18n translation check
feat: i18n translation check
2025-09-29 10:58:35 +02:00
Levente Orban
bfb76aa268 feat: remove hu.json due the mistakes 2025-09-29 10:55:23 +02:00
Levente Orban
f8758d7b47 feat: add i18n translatation check 2025-09-29 08:42:15 +02:00
Levente Orban
f51f89e35f Merge pull request #28 from polaroi8d/fix/location-missing-field
fix: location missing field error
2025-09-25 09:33:56 +02:00
Levente Orban
26824eb3a8 fix: location missing field error 2025-09-25 09:28:44 +02:00
30 changed files with 976 additions and 178 deletions

View File

@@ -13,3 +13,5 @@ DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_data
APP_VERSION=latest
PORT=3000
HOSTNAME=0.0.0.0
PUBLIC_LANDING_INFO=true

View File

@@ -60,6 +60,8 @@ jobs:
with:
context: .
file: ./Dockerfile
build-args: |
PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }}
push: true
platforms: linux/amd64,linux/arm64
cache-from: type=gha

View File

@@ -28,6 +28,8 @@ jobs:
- name: Build application
run: npm run build
env:
PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }}
- name: Test build output
run: |

View File

@@ -3,25 +3,27 @@ WORKDIR /app
COPY package*.json .
RUN npm ci
ARG PUBLIC_LANDING_INFO
ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO
COPY . .
RUN npm run build
RUN npm prune --production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
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
EXPOSE 3000
CMD [ "node", "build" ]

View File

@@ -1,4 +1,4 @@
.PHONY: help build up db-only logs db-clean prune
.PHONY: help build up db-only logs db-clean prune i18n lint format
# Default target
help:
@@ -15,6 +15,7 @@ help:
@echo " make logs - Show logs from all services"
@echo " make db-clean - Stop & remove database container"
@echo " make prune - Remove all containers, images, and volumes"
@echo " make i18n - Validate translation files against messages.json"
@echo " make help - Show this help message"
# Build the Docker images
@@ -46,4 +47,21 @@ prune:
@echo "Cleaning up all Docker resources..."
docker compose down -v --rmi all
# Validate translation files
i18n:
@echo "Validating translation files..."
@if [ -n "$(FILE)" ]; then \
./scripts/i18n-check.sh $(FILE); \
else \
./scripts/i18n-check.sh; \
fi
lint:
@echo "Linting the project..."
npm run lint
format:
@echo "Formatting the project..."
npm run format

View File

@@ -55,8 +55,48 @@ Your app will be available at `http://localhost:5173`. You can use the Makefile
Use the `database/seed.sql` if you want to populate your database with dummy data.
### Options
#### 1. Landing page option
Supports a conditional landing page display based on the `PUBLIC_LANDING_INFO` environment variable. If you don't want to show your users the cactoide landing page, just use the `PUBLIC_LANDING_INFO=false` variable. This will automatically remove the landing home page and redirect users to the `/discover` page.
This is useful for:
- Creating a minimal discovery-focused experience
- Customizing the user journey based on deployment environment
#### 2. i18n
There is no proper i18n implemented, we have an `/i18n` folder with specific languages. To use an existing translation, just rename the language code JSON file to `messages.json` and you are ready to go. If you would like to add a new translation (which is really appreciated), just create a new `<language_code>.json` file and add the translations from the `messages.json`.
The project includes a translation validation script to ensure all translation files are complete and up-to-date with the source `messages.json` file.
```bash
# Validate all translation files
make i18n
```
```bash
# Validate a specific translation file
make i18n FILE=src/lib/i18n/it.json
```
### Support
Cactoide is an open-source project licensed under `AGPL-3.0`. Its growth and development are possible thanks to the amazing support of the community. This project is the result of many late nights, weekends, and after-hours work.
It isnt backed by a big company. Development depends on the support and generosity of people like you. With your help, I can focus more on making Cactoide even better and building tools that make coding more enjoyable.
You can support in a few ways:
- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben)
- Reach me directly: leventeorb[@]gmail.com
If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community.
### License
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
This project is licensed under the `AGPL-3.0 License` - see the [LICENSE](./LICENSE) file for details.
**Made with ❤️ by @polaroi8d**

View File

@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS events (
date DATE NOT NULL,
time TIME NOT NULL,
location VARCHAR(200) NOT NULL,
location_type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (location_type IN ('text','maps')),
location_type VARCHAR(20) NOT NULL DEFAULT 'none' CHECK (location_type IN ('none','text','maps')),
location_url VARCHAR(500),
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
attendee_limit INTEGER CHECK (attendee_limit > 0),

92
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "event-cactus",
"version": "0.0.1",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-cactus",
"version": "0.0.1",
"version": "0.1.1",
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5",
@@ -35,7 +35,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
"vite": "^7.1.11"
}
},
"node_modules/@drizzle-team/brocli": {
@@ -1949,6 +1949,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@@ -2602,9 +2662,9 @@
}
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz",
"integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==",
"license": "MIT"
},
"node_modules/drizzle-kit": {
@@ -4737,13 +4797,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -4864,17 +4924,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"

View File

@@ -1,7 +1,7 @@
{
"name": "cactoide",
"private": true,
"version": "0.0.3",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -35,7 +35,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
"vite": "^7.1.11"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",

191
scripts/i18n-check.sh Executable file
View File

@@ -0,0 +1,191 @@
#!/bin/bash
# Translation validation script
# Compares a translation file against the source messages.json to find missing keys
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Default paths
SOURCE_FILE="src/lib/i18n/messages.json"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Function to show usage
show_usage() {
echo "Usage: $0 [LANGUAGE_FILE]"
echo ""
echo "Validates a translation file against the source messages.json"
echo ""
echo "Arguments:"
echo " LANGUAGE_FILE Path to the translation file to validate (e.g., src/lib/i18n/it.json)"
echo ""
echo "Examples:"
echo " $0 src/lib/i18n/it.json"
echo " $0 src/lib/i18n/fr.json"
echo ""
echo "If no file is provided, it will check all .json files in src/lib/i18n/ except messages.json"
}
# Function to get all keys from a JSON file recursively
get_keys() {
local file="$1"
local prefix="$2"
# Use jq to extract all keys recursively
jq -r 'paths(scalars) as $p | $p | join(".")' "$file" | while read -r key; do
if [ -n "$prefix" ]; then
echo "${prefix}.${key}"
else
echo "$key"
fi
done
}
# Function to validate a single translation file
validate_file() {
local translation_file="$1"
local source_file="$2"
echo -e "${YELLOW}Validating: $translation_file${NC}"
echo "----------------------------------------"
# Check if files exist
if [ ! -f "$source_file" ]; then
echo -e "${RED}Error: Source file $source_file not found${NC}"
return 1
fi
if [ ! -f "$translation_file" ]; then
echo -e "${RED}Error: Translation file $translation_file not found${NC}"
return 1
fi
# Get all keys from source file
local source_keys
source_keys=$(get_keys "$source_file")
# Get all keys from translation file
local translation_keys
translation_keys=$(get_keys "$translation_file")
# Find missing keys
local missing_keys
missing_keys=$(comm -23 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Find extra keys (in translation but not in source)
local extra_keys
extra_keys=$(comm -13 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Count missing and extra keys
local missing_count
if [ -z "$missing_keys" ]; then
missing_count=0
else
missing_count=$(echo "$missing_keys" | wc -l | tr -d ' ')
fi
local extra_count
if [ -z "$extra_keys" ]; then
extra_count=0
else
extra_count=$(echo "$extra_keys" | wc -l | tr -d ' ')
fi
# Report results
if [ "$missing_count" -eq 0 ] && [ "$extra_count" -eq 0 ]; then
echo -e "${GREEN} Perfect! All keys match.${NC}"
return 0
fi
if [ "$missing_count" -gt 0 ]; then
echo -e "${RED} Missing $missing_count key(s) in translation:${NC}"
echo "$missing_keys" | while read -r key; do
echo -e " ${RED}$key${NC}"
done
echo ""
fi
if [ "$extra_count" -gt 0 ]; then
echo -e "${YELLOW} Extra $extra_count key(s) in translation (not in source):${NC}"
echo "$extra_keys" | while read -r key; do
echo -e " ${YELLOW}$key${NC}"
done
echo ""
fi
# Return error code if there are missing keys
if [ "$missing_count" -gt 0 ]; then
return 1
fi
return 0
}
# Main function
main() {
local translation_file="$1"
local source_file="$PROJECT_ROOT/$SOURCE_FILE"
local exit_code=0
# Change to project root directory
cd "$PROJECT_ROOT"
# If no file specified, check all translation files
if [ -z "$translation_file" ]; then
echo -e "${YELLOW}No file specified. Checking all translation files...${NC}"
echo ""
# Find all .json files in i18n directory except messages.json
local files
files=$(find src/lib/i18n -name "*.json" -not -name "messages.json" 2>/dev/null || true)
if [ -z "$files" ]; then
echo -e "${YELLOW}No translation files found in src/lib/i18n/${NC}"
return 0
fi
# Validate each file
echo "$files" | while read -r file; do
if [ -n "$file" ]; then
if ! validate_file "$file" "$source_file"; then
exit_code=1
fi
echo ""
fi
done
return $exit_code
fi
# Validate the specified file
if ! validate_file "$translation_file" "$source_file"; then
exit_code=1
fi
return $exit_code
}
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo -e "${RED}Error: jq is required but not installed.${NC}"
echo "Please install jq:"
echo " macOS: brew install jq"
echo " Ubuntu/Debian: sudo apt-get install jq"
echo " CentOS/RHEL: sudo yum install jq"
exit 1
fi
# Handle help flag
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_usage
exit 0
fi
# Run main function
main "$1"

44
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,44 @@
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { generateUserId } from '$lib/generateUserId.js';
import { ensureDatabaseConnection } from '$lib/database/healthCheck';
// Global flag to track if database health check has been performed
let dbHealthCheckPerformed = false;
export const handle: Handle = async ({ event, resolve }) => {
// Perform database health check only once during application startup
if (!dbHealthCheckPerformed) {
try {
await ensureDatabaseConnection({
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
timeout: 5000
});
dbHealthCheckPerformed = true;
} catch (error) {
console.error('Database health check failed:', error);
// The ensureDatabaseConnection function will exit the process
// if the database is unavailable, so this catch is just for safety
process.exit(1);
}
}
const cactoideUserId = event.cookies.get('cactoideUserId');
const userId = generateUserId();
const DAYS = 400; // practical upper bound in many browsers for cookies
const MAX_AGE = 60 * 60 * 24 * DAYS;
const PATH = '/';
if (!cactoideUserId) {
console.debug(`There is no cactoideUserId cookie, generating new one...`);
event.cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
} else {
console.debug(`cactoideUserId: ${cactoideUserId}`);
console.debug(`cactoideUserId cookie found, using existing one...`);
}
return resolve(event);
};

View File

@@ -15,7 +15,10 @@ export interface CalendarEvent {
* Formats a date and time string for iCal format (UTC)
*/
export const formatDateForICal = (date: string, time: string): string => {
const eventDate = new Date(`${date}T${time}`);
// Parse date and time as local timezone to avoid timezone issues
const [year, month, day] = date.split('-').map(Number);
const [hours, minutes, seconds] = time.split(':').map(Number);
const eventDate = new Date(year, month - 1, day, hours, minutes, seconds || 0);
return eventDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};

View File

@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js';
import { PUBLIC_LANDING_INFO } from '$env/static/public';
// Check if current page is active
const isActive = (path: string): boolean => {
@@ -24,12 +25,14 @@
<!-- Navigation -->
<div class="md:flex md:items-center md:space-x-8">
<button
on:click={() => goto('/')}
class={isActive('/') ? 'text-violet-400' : 'cursor-pointer'}
>
{t('navigation.home')}
</button>
{#if PUBLIC_LANDING_INFO !== 'false'}
<button
on:click={() => goto('/')}
class={isActive('/') ? 'text-violet-400' : 'cursor-pointer'}
>
{t('navigation.home')}
</button>
{/if}
<button
on:click={() => goto('/discover')}

View File

@@ -0,0 +1,104 @@
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
interface HealthCheckOptions {
maxRetries?: number;
baseDelay?: number;
maxDelay?: number;
timeout?: number;
}
interface HealthCheckResult {
success: boolean;
error?: string;
attempts: number;
duration?: number;
}
/**
* Performs a database health check with retry logic and exponential backoff
*/
export async function checkDatabaseHealth(
options: HealthCheckOptions = {}
): Promise<HealthCheckResult> {
const {
maxRetries = 3,
baseDelay = 1000, // 1 second
maxDelay = 10000, // 10 seconds
timeout = 5000 // 5 seconds
} = options;
if (!env.DATABASE_URL) {
console.error('DATABASE_URL environment variable is not set');
return {
success: false,
error: 'DATABASE_URL environment variable is not set',
attempts: 0
};
}
let lastError: Error | null = null;
console.log(`Starting database health check (max retries: ${maxRetries})`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`Attempt ${attempt}/${maxRetries} - Testing database connection`);
try {
// Create a new connection for the health check
const client = postgres(env.DATABASE_URL, {
max: 1,
idle_timeout: 20,
connect_timeout: timeout / 1000, // Convert to seconds
onnotice: () => {} // Suppress notices
});
// Test the connection with a simple query
await client`SELECT 1 as health_check`;
await client.end();
console.log(`Database connection successful on attempt ${attempt}.`);
return {
success: true,
attempts: attempt
};
} catch (error) {
lastError = error as Error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`Connection failed on attempt ${attempt}: ${errorMessage}`);
// Don't wait after the last attempt
if (attempt < maxRetries) {
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
console.log(`Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
const finalError = lastError?.message || 'Unknown database connection error';
console.error(`All ${maxRetries} attempts failed. Final error: ${finalError}`);
return {
success: false,
error: finalError,
attempts: maxRetries
};
}
/**
* Runs database health check and exits the process if it fails
*/
export async function ensureDatabaseConnection(options?: HealthCheckOptions): Promise<void> {
const result = await checkDatabaseHealth(options);
if (!result.success) {
console.error('Database connection failed after all retry attempts');
console.error(`Error: ${result.error}`);
console.error(`Attempts made: ${result.attempts}`);
console.error('Exiting application...');
process.exit(1);
}
}

View File

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

View File

@@ -1,11 +1,14 @@
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}`;
// Parse the date string as local date to avoid timezone issues
// Split the date string and create a Date object in local timezone
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(year, month - 1, day); // month is 0-indexed in Date constructor
const formattedYear = date.getFullYear();
const formattedMonth = String(date.getMonth() + 1).padStart(2, '0');
const formattedDay = String(date.getDate()).padStart(2, '0');
return `${formattedYear}/${formattedMonth}/${formattedDay}`;
};
export const formatTime = (timeString: string): string => {
@@ -17,7 +20,10 @@ export const formatTime = (timeString: string): string => {
export const isEventInTimeRange = (event: Event, timeFilter: string): boolean => {
if (timeFilter === 'any') return true;
const eventDate = new Date(`${event.date}T${event.time}`);
// Parse date and time as local timezone to avoid timezone issues
const [year, month, day] = event.date.split('-').map(Number);
const [hours, minutes, seconds] = event.time.split(':').map(Number);
const eventDate = new Date(year, month - 1, day, hours, minutes, seconds || 0);
const now = new Date();
// Handle temporal status filters

264
src/lib/i18n/it.json Normal file
View File

@@ -0,0 +1,264 @@
{
"common": {
"required": "*",
"cancel": "Annulla",
"create": "Crea",
"edit": "Modifica",
"delete": "Elimina",
"view": "Visualizza",
"home": "Home",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"name": "Nome",
"date": "Data",
"time": "Ora",
"location": "Luogo",
"locationType": "Tipo di Luogo",
"locationNone": "Nessuno",
"locationText": "Testo",
"locationMaps": "Google Maps",
"locationNoneDescription": "Nessun luogo specificato",
"locationTextDescription": "Inserisci il luogo come testo semplice.",
"locationMapsDescription": "Inserisci il link di Google Maps.",
"googleMapsUrl": "URL di Google Maps",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"type": "Tipo",
"visibility": "Visibilità",
"public": "Pubblico",
"private": "Privato",
"limited": "Limitato",
"unlimited": "Illimitato",
"capacity": "Capacità",
"attendees": "Partecipanti",
"attendeeLimit": "Limite di Partecipanti",
"enterLimit": "Inserisci il limite",
"enterEventName": "Inserisci il nome dell'evento",
"enterLocation": "Inserisci il luogo",
"enterYourName": "Inserisci il tuo nome",
"enterNumberOfGuests": "Inserisci il numero di ospiti",
"yourName": "Il tuo nome",
"numberOfGuests": "Numero di Ospiti",
"addGuests": "Aggiungi ospiti",
"joinEvent": "Partecipa all'Evento",
"copyLink": "Copia Link",
"addToCalendar": "Aggiungi al Calendario",
"close": "Chiudi",
"closeModal": "Chiudi finestra",
"removeRSVP": "Rimuovi RSVP",
"updating": "Aggiornamento...",
"creating": "Creazione...",
"adding": "Aggiunta...",
"updateEvent": "Aggiorna Evento",
"createEvent": "Crea Evento",
"createNewEvent": "Crea Nuovo Evento",
"createYourFirstEvent": "Crea il Tuo Primo Evento",
"editEvent": "Modifica Evento",
"deleteEvent": "Elimina Evento",
"myEvents": "I Miei Eventi",
"discover": "Scopri",
"noEventsYet": "Ancora Nessun Evento",
"noPublicEventsYet": "Ancora Nessun Evento Pubblico",
"noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!",
"eventNotFound": "Evento Non Trovato",
"eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
"somethingWentWrong": "Qualcosa è andato storto. Riprova.",
"failedToAddRsvp": "Impossibile aggiungere RSVP",
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
"failedToDeleteEvent": "Impossibile eliminare l'evento",
"youMayNotHavePermission": "Potresti non avere il permesso di eliminare questo evento.",
"anErrorOccurredWhileDeleting": "Si è verificato un errore durante l'eliminazione dell'evento:",
"databaseUnreachable": "Database non raggiungibile.",
"eventIdNotFound": "EventId non trovato",
"eventNotExists": "Evento non trovato",
"failedToLoadEvent": "Impossibile caricare l'evento",
"nameAndUserIdRequired": "Nome e ID utente sono obbligatori",
"eventCapacityExceeded": "Capacità dell'evento superata. Stai cercando di aggiungere {guests} partecipanti (te compreso/a), ma rimangono solo {remaining} posti.",
"nameAlreadyExists": "Il nome esiste già per questo evento",
"missingOrEmptyFields": "Campi mancanti o vuoti: {fields}",
"dateCannotBeInPast": "La data non può essere nel passato.",
"limitMustBeAtLeast2": "Il limite deve essere almeno 2 per eventi limitati.",
"unauthorized": "Non autorizzato",
"youCanOnlyEditYourOwnEvents": "Puoi modificare solo i tuoi eventi",
"youDoNotHavePermissionToDelete": "Non hai il permesso di eliminare questo evento",
"eventIdAndUserIdRequired": "ID evento e ID utente sono obbligatori",
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
"yourNamePlaceholder": "Il tuo nome",
"atTime": "alle"
},
"navigation": {
"home": "Home",
"discover": "Scopri",
"create": "Crea",
"myEvents": "I Miei Eventi"
},
"home": {
"title": "Cactoide - Il sito per gli RSVP",
"description": "Crea e gestisci gli RSVP degli eventi. Nessuna registrazione richiesta, condivisione immediata.",
"mainTitle": "Cactoide(ea)",
"subtitle": "La Piattaforma Definitiva per gli RSVP",
"tagline": "Crea, condividi e gestisci eventi senza intoppi.",
"whyCactoideTitle": "Perché Cactoide(ae)? 🌵",
"whyCactoideDescription": "Come il cactus, i grandi eventi fioriscono in ogni condizione se gestiti con cura. Cactoide(ae) ti aiuta a semplificare gli RSVP, coordinare in modo semplice e mantenere ogni dettaglio efficiente: così i tuoi incontri sono resilienti, vivaci e indimenticabili.",
"createEventNow": "Crea Evento Ora",
"discoverPublicEventsTitle": "Scopri Eventi Pubblici",
"discoverPublicEventsDescription": "Guarda cosa stanno pianificando gli altri e lasciati ispirare",
"browseAllPublicEvents": "Sfoglia Tutti gli Eventi Pubblici",
"whyCactoideFeatureTitle": "Perché Cactoide?",
"instantEventCreationTitle": "Creazione Istantanea di Eventi",
"instantEventCreationDescription": "Crea eventi in pochi secondi con il nostro modulo semplificato. Nessun account, nessuna attesa, solo pura efficienza.",
"oneClickSharingTitle": "Condivisione con un Clic",
"oneClickSharingDescription": "Ogni evento ottiene un URL unico e memorabile. Condividi istantaneamente tramite qualsiasi piattaforma o app di messaggistica.",
"allInOneClarityTitle": "Chiarezza Tutto-in-Uno",
"allInOneClarityDescription": "Niente più scorrimento infinito tra chat e reazioni. Visualizza la disponibilità e le risposte di tutti in un unico posto.",
"noHassleNoSignUpsTitle": "Nessun Problema, Nessuna Registrazione",
"noHassleNoSignUpsDescription": "Salta le registrazioni e i moduli infiniti. A differenza di altre piattaforme di eventi, crei e condividi istantaneamente: nessun account, nessuna barriera.",
"smartLimitsTitle": "Limiti Intelligenti",
"smartLimitsDescription": "Scegli tra RSVP illimitati o imposta una capacità limitata. Perfetto per eventi di qualsiasi dimensione.",
"effortlessSimplicityTitle": "Semplicità Senza Sforzo",
"effortlessSimplicityDescription": "Progettato per essere immediatamente chiaro e facile. Nessuna curva di apprendimento: apri, crea e vai.",
"howItWorksTitle": "Come Funziona",
"step1Title": "Crea Evento",
"step1Description": "Compila un semplice modulo con i dettagli dell'evento. Scegli tra capacità limitata o illimitata.",
"step2Title": "Ottieni URL Unico",
"step2Description": "Ricevi un URL casuale e memorabile per il tuo evento. Perfetto per la condivisione ovunque.",
"step3Title": "Raccogli gli RSVP",
"step3Description": "Le persone visitano il tuo link e partecipano solo con il loro nome. Nessun account necessario.",
"ctaTitle": "Pronto a Creare il Tuo Primo Evento?",
"ctaDescription": "Unisciti a migliaia di organizzatori di eventi che si fidano di Cactoide",
"ctaButton": "Crea"
},
"create": {
"title": "Crea Evento - Cactoide",
"formTitle": "Crea Nuovo Evento",
"eventNameLabel": "Nome",
"eventNamePlaceholder": "Inserisci il nome dell'evento",
"dateLabel": "Data",
"timeLabel": "Ora",
"locationLabel": "Luogo",
"locationPlaceholder": "Inserisci il luogo",
"locationTypeLabel": "Tipo di Luogo",
"locationNoneOption": "Nessuno",
"locationTextOption": "Testo Semplice",
"locationMapsOption": "Google Maps",
"locationNoneDescription": "Nessun luogo specificato.",
"locationTextDescription": "Inserisci il luogo come testo semplice.",
"locationMapsDescription": "Inserisci il link di Google Maps.",
"googleMapsUrlLabel": "URL di Google Maps",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"typeLabel": "Tipo",
"unlimitedOption": "Illimitato",
"limitedOption": "Limitato",
"attendeeLimitLabel": "Limite di Partecipanti",
"attendeeLimitPlaceholder": "Inserisci il limite",
"visibilityLabel": "Visibilità",
"publicOption": "🌍 Pubblico",
"privateOption": "🔒 Privato",
"publicDescription": "Gli eventi pubblici sono visibili a tutti e possono essere scoperti da altri.",
"privateDescription": "Gli eventi privati sono visibili solo a te e alle persone con cui condividi il link.",
"creatingEvent": "Creazione Evento...",
"createEventButton": "Crea Evento"
},
"event": {
"title": "{eventName} - Cactoide",
"eventTitle": "Evento - Cactoide",
"editTitle": "Modifica Evento - {eventName} - Cactoide",
"myEventsTitle": "I Miei Eventi - Cactoide",
"eventNotFoundTitle": "Evento Non Trovato",
"eventNotFoundDescription": "L'evento che stai cercando non esiste o è stato rimosso.",
"joinThisEvent": "Partecipa a Questo Evento",
"eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima",
"yourNameLabel": "Il tuo nome",
"yourNamePlaceholder": "Inserisci il tuo nome",
"addGuestsLabel": "Aggiungi ospiti",
"numberOfGuestsLabel": "Numero di Ospiti",
"numberOfGuestsPlaceholder": "Inserisci il numero di ospiti",
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
"joinEventButton": "Partecipa all'Evento",
"joinEventWithGuests": "Partecipa all'Evento + {count} Ospite{plural}",
"adding": "Aggiunta...",
"attendeesTitle": "Partecipanti",
"noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!",
"copyLinkButton": "Copia Link",
"addToCalendarButton": "Aggiungi al Calendario",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"failedToAddRsvp": "Impossibile aggiungere RSVP",
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
"editEventTitle": "Modifica Evento",
"editEventDescription": "Aggiorna i dettagli del tuo evento",
"updatingEvent": "Aggiornamento...",
"updateEventButton": "Aggiorna Evento",
"myEventsDescription": "Gestisci i tuoi eventi creati",
"noEventsYetTitle": "Ancora Nessun Evento",
"noEventsYetDescription": "Non hai ancora creato nessun evento. Inizia creando il tuo primo evento!",
"createYourFirstEventButton": "Crea il Tuo Primo Evento",
"deleteEventTitle": "Elimina Evento",
"deleteEventDescription": "Sei sicuro di voler eliminare \"{eventName}\"? Questa azione non può essere annullata e rimuoverà tutti gli RSVP.",
"deleteButton": "Elimina",
"viewEventAriaLabel": "Visualizza evento",
"editEventAriaLabel": "Modifica evento",
"deleteEventAriaLabel": "Elimina evento",
"removeRsvpAriaLabel": "Rimuovi RSVP"
},
"discover": {
"title": "Scopri Eventi - Cactoide",
"noPublicEventsTitle": "Ancora Nessun Evento Pubblico",
"noPublicEventsDescription": "Al momento non ci sono eventi pubblici disponibili. Sii il primo a crearne uno!",
"createButton": "Crea",
"publicEventsTitle": "Eventi Pubblici ({count})",
"publicEventsDescription": "Scopri eventi creati dalla comunità",
"searchPlaceholder": "Cerca eventi per nome, luogo...",
"searchInputAriaLabel": "Input di ricerca",
"toggleFiltersAriaLabel": "Attiva/Disattiva filtri",
"typeFilterLabel": "Tipo:",
"typeFilterAll": "Tutti",
"typeFilterLimited": "Limitati",
"typeFilterUnlimited": "Illimitati",
"statusFilterLabel": "Stato:",
"statusFilterAll": "Tutti gli eventi",
"statusFilterUpcoming": "Eventi imminenti",
"statusFilterPast": "Eventi passati",
"timeFilterLabel": "Orario:",
"timeFilterAny": "Qualsiasi orario",
"timeFilterNextWeek": "Prossima settimana",
"timeFilterNextMonth": "Prossimo mese",
"sortOrderLabel": "Ordina:",
"sortOrderEarliest": "Prima i più vicini",
"sortOrderLatest": "Prima i più recenti",
"viewButton": "Visualizza",
"noEventsFoundTitle": "Nessun evento trovato",
"noEventsFoundDescription": "Prova a modificare i termini di ricerca o sfoglia tutti gli eventi"
},
"calendar": {
"addToCalendarTitle": "Aggiungi al Calendario",
"googleCalendarTitle": "Google Calendar",
"googleCalendarDescription": "Aggiungi a Google Calendar",
"microsoftOutlookTitle": "Microsoft Outlook",
"microsoftOutlookDescription": "Aggiungi a Outlook Calendar",
"downloadICalTitle": "Scarica File iCal",
"downloadICalDescription": "Scarica file .ics per qualsiasi app di calendario"
},
"errors": {
"title": "Errore - Cactoide",
"errorTitle": "Errore",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
"homeButton": "Home"
},
"layout": {
"defaultTitle": "Cactoide -",
"defaultDescription": "Crea e gestisci gli RSVP degli eventi",
"userIdCookieText": "Il tuo UserID memorizzato come cookie:",
"firstTimeVisiting": "Prima visita. Generazione di un nuovo UserID...",
"copyright": "© 2025 Cactoide"
}
}

View File

@@ -15,8 +15,10 @@
"time": "Time",
"location": "Location",
"locationType": "Location Type",
"locationNone": "None",
"locationText": "Text",
"locationMaps": "Google Maps",
"locationNoneDescription": "No location specified",
"locationTextDescription": "Enter location as plain text.",
"locationMapsDescription": "Enter Google Maps link.",
"googleMapsUrl": "Google Maps URL",
@@ -142,10 +144,12 @@
"locationLabel": "Location",
"locationPlaceholder": "Enter location",
"locationTypeLabel": "Location Type",
"locationNoneOption": "None",
"locationTextOption": "Plain Text",
"locationMapsOption": "Google Maps",
"locationTextDescription": "Enter location as plain text",
"locationMapsDescription": "Enter Google Maps link",
"locationNoneDescription": "No location specified.",
"locationTextDescription": "Enter location as plain text.",
"locationMapsDescription": "Enter Google Maps link.",
"googleMapsUrlLabel": "Google Maps URL",
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
"typeLabel": "Type",
@@ -156,8 +160,8 @@
"visibilityLabel": "Visibility",
"publicOption": "🌍 Public",
"privateOption": "🔒 Private",
"publicDescription": "Public events are visible to everyone and can be discovered by others",
"privateDescription": "Private events are only visible to you and people you share the link with",
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
"privateDescription": "Private events are only visible to you and people you share the link with.",
"creatingEvent": "Creating Event...",
"createEventButton": "Create Event"
},

View File

@@ -1,7 +1,7 @@
export type EventType = 'limited' | 'unlimited';
export type EventVisibility = 'public' | 'private';
export type ActionType = 'add' | 'remove';
export type LocationType = 'text' | 'maps';
export type LocationType = 'none' | 'text' | 'maps';
export interface Event {
id: string;

View File

@@ -1,20 +1,5 @@
import { generateUserId } from '$lib/generateUserId.js';
export function load({ cookies }) {
const cactoideUserId = cookies.get('cactoideUserId');
const userId = generateUserId();
const DAYS = 400; // practical upper bound in many browsers for cookies
const MAX_AGE = 60 * 60 * 24 * DAYS;
const PATH = '/';
if (!cactoideUserId) {
console.debug(`There is no cactoideUserId cookie, generating new one...`);
cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
} else {
console.debug(`cactoideUserId: ${cactoideUserId}`);
console.debug(`cactoideUserId cookie found, using existing one...`);
}
return {
cactoideUserId

View File

@@ -3,7 +3,7 @@
import Navbar from '$lib/components/Navbar.svelte';
import { t } from '$lib/i18n/i18n.js';
let { data } = $props();
let { data, children } = $props();
</script>
<svelte:head>
@@ -21,7 +21,7 @@
<!-- Main content -->
<main class="relative z-10">
<slot />
{@render children?.()}
</main>
<!-- Footer -->

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { PUBLIC_LANDING_INFO } from '$env/static/public';
export const load: PageServerLoad = async () => {
if (PUBLIC_LANDING_INFO === 'false') {
throw redirect(302, '/discover');
}
return {};
};

View File

@@ -25,7 +25,7 @@
<h2 class="mt-6 pt-8 text-xl md:text-2xl">
{t('home.whyCactoideTitle')}<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">
{t('home.whyCactoideDescription')}

View File

@@ -21,7 +21,7 @@ export const actions: Actions = {
const date = formData.get('date') as string;
const time = formData.get('time') as string;
const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'text' | 'maps';
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
@@ -34,8 +34,8 @@ export const actions: Actions = {
if (!name?.trim()) missingFields.push('name');
if (!date) missingFields.push('date');
if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (!userId) missingFields.push('userId');
@@ -56,7 +56,13 @@ export const actions: Actions = {
});
}
if (new Date(date) < new Date()) {
// Check if date is in the past using local timezone
const [year, month, day] = date.split('-').map(Number);
const eventDate = new Date(year, month - 1, day);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (eventDate < today) {
return fail(400, {
error: 'Date cannot be in the past.',
values: {
@@ -99,13 +105,13 @@ export const actions: Actions = {
name: name.trim(),
date: date,
time: time,
location: location.trim(),
location: location?.trim() || '',
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility,
userId: userId
userId: userId!
})
.catch((error) => {
console.error('Unexpected error', error);

View File

@@ -11,7 +11,7 @@
date: '',
time: '',
location: '',
location_type: 'text',
location_type: 'none',
location_url: '',
type: 'unlimited',
attendee_limit: undefined,
@@ -50,7 +50,10 @@
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'text') {
if (locationType === 'none') {
eventData.location = '';
eventData.location_url = '';
} else if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
@@ -70,7 +73,7 @@
<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">
<div class="mx-auto max-w-2xl">
<!-- Event Creation Form -->
<div class="rounded-sm border p-8">
<h2 class="mb-8 text-center text-3xl font-bold text-violet-400">{t('create.formTitle')}</h2>
@@ -168,7 +171,17 @@
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'none'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('none')}
>
{t('create.locationNoneOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
@@ -190,52 +203,56 @@
{t('create.locationMapsOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
<p class="mt-2 text-xs text-slate-400 italic">
{eventData.location_type === 'none'
? t('create.locationNoneDescription')
: eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input -->
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<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={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
<!-- Location Input (only show when not 'none') -->
{#if eventData.location_type !== 'none'}
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<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={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
{/if}
<!-- Event Type -->
<div>
@@ -274,7 +291,7 @@
<div>
<label for="limit" class="text-dark-800 mb-3 block text-sm font-semibold">
{t('create.attendeeLimitLabel')}
{t('common.required')}
<span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="attendee_limit"
@@ -322,7 +339,7 @@
{t('create.privateOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
<p class="mt-2 text-xs text-slate-400 italic">
{eventData.visibility === 'public'
? t('create.publicDescription')
: t('create.privateDescription')}
@@ -342,7 +359,7 @@
<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"
class="hover:bg-violet-400/70'l flex-2 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">

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import type { Event, EventType } from '$lib/types';
import { goto } from '$app/navigation';
import type { PageData } from '../$types';
import { formatTime, formatDate, isEventInTimeRange } from '$lib/dateHelpers';
import { t } from '$lib/i18n/i18n.js';
import Fuse from 'fuse.js';
type DiscoverPageData = {
events: Event[];
};
let publicEvents: Event[] = [];
let error = '';
let searchQuery = '';
@@ -16,7 +19,7 @@
let showFilters = false;
let fuse: Fuse<Event>;
export let data: PageData;
export let data: DiscoverPageData;
// Use the server-side data
$: publicEvents = data?.events || [];
@@ -67,8 +70,15 @@
// 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}`);
// Parse dates as local timezone to avoid timezone issues
const parseEventDateTime = (event: Event) => {
const [year, month, day] = event.date.split('-').map(Number);
const [hours, minutes, seconds] = event.time.split(':').map(Number);
return new Date(year, month - 1, day, hours, minutes, seconds || 0);
};
const dateA = parseEventDateTime(a);
const dateB = parseEventDateTime(b);
if (selectedSortOrder === 'asc') {
return dateA.getTime() - dateB.getTime();
@@ -289,14 +299,16 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
{#if event.location_type === 'maps' && event.location_url}
{#if event.location_type === 'none'}
<span>N/A</span>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
{event.location}
{t('create.locationMapsOption')}
</a>
{:else}
<span>{event.location}</span>

View File

@@ -126,14 +126,16 @@
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
{#if event.location_type === 'maps' && event.location_url}
{#if event.location_type === 'none'}
<span>N/A</span>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
>
{event.location}
Google Maps
</a>
{:else}
<span>{event.location}</span>

View File

@@ -106,7 +106,7 @@
</div>
</div>
{:else if event}
<div class="mx-auto max-w-md space-y-6">
<div class="mx-auto max-w-2xl space-y-6">
<!-- Event Details Card -->
<div class="rounded-sm border p-6 shadow-2xl">
@@ -136,7 +136,7 @@
</div>
</div>
<!-- Location -->
<!-- Location (only show when not 'none') -->
<div class="flex items-center space-x-3 text-violet-400">
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -155,14 +155,16 @@
</svg>
</div>
<div>
{#if event.location_type === 'maps' && event.location_url}
{#if event.location_type === 'none'}
<p class="font-semibold text-white">N/A</p>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
>
{event.location}
{t('create.locationMapsOption')}
</a>
{:else}
<p class="font-semibold text-white">{event.location}</p>

View File

@@ -53,7 +53,7 @@ export const actions: Actions = {
const date = formData.get('date') as string;
const time = formData.get('time') as string;
const location = formData.get('location') as string;
const locationType = formData.get('location_type') as 'text' | 'maps';
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
@@ -65,8 +65,8 @@ export const actions: Actions = {
if (!name?.trim()) missingFields.push('name');
if (!date) missingFields.push('date');
if (!time) missingFields.push('time');
if (!location?.trim()) missingFields.push('location');
if (!locationType) missingFields.push('location_type');
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
if (missingFields.length > 0) {
@@ -86,8 +86,9 @@ export const actions: Actions = {
});
}
// Check if date is in the past (but allow editing past events for corrections)
const eventDate = new Date(date);
// Check if date is in the past using local timezone (but allow editing past events for corrections)
const [year, month, day] = date.split('-').map(Number);
const eventDate = new Date(year, month - 1, day);
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -132,7 +133,7 @@ export const actions: Actions = {
name: name.trim(),
date: date,
time: time,
location: location.trim(),
location: location?.trim() || '',
locationType: locationType,
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
type: type,

View File

@@ -12,7 +12,7 @@
date: data.event.date,
time: data.event.time,
location: data.event.location,
location_type: data.event.locationType || 'text',
location_type: data.event.locationType || 'none',
location_url: data.event.locationUrl || '',
type: data.event.type,
attendee_limit: data.event.attendeeLimit,
@@ -53,7 +53,10 @@
const handleLocationTypeChange = (locationType: LocationType) => {
eventData.location_type = locationType;
if (locationType === 'text') {
if (locationType === 'none') {
eventData.location = '';
eventData.location_url = '';
} else if (locationType === 'text') {
eventData.location_url = '';
eventData.location = '';
} else {
@@ -73,7 +76,7 @@
<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">
<div class="mx-auto max-w-2xl">
<!-- Event Edit Form -->
<div class="rounded-sm border p-8">
<div class="mb-8 text-center">
@@ -169,7 +172,17 @@
{t('create.locationTypeLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
'none'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
on:click={() => handleLocationTypeChange('none')}
>
{t('create.locationNoneOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
@@ -192,51 +205,55 @@
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
{eventData.location_type === 'none'
? t('create.locationNoneDescription')
: eventData.location_type === 'text'
? t('create.locationTextDescription')
: t('create.locationMapsDescription')}
</p>
</fieldset>
</div>
<!-- Location Input -->
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationTypeLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<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={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
<!-- Location Input (only show when not 'none') -->
{#if eventData.location_type !== 'none'}
<div>
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
{eventData.location_type === 'text'
? t('create.locationLabel')
: t('create.googleMapsUrlLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
{#if eventData.location_type === 'text'}
<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={t('create.locationPlaceholder')}
maxlength="200"
required
/>
{:else}
<input
id="location_url"
name="location_url"
type="url"
bind:value={eventData.location_url}
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
placeholder={t('create.googleMapsUrlPlaceholder')}
maxlength="500"
required
/>
{/if}
{#if errors.location}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
{/if}
{#if errors.location_url}
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
{/if}
</div>
{/if}
<!-- Event Type -->
<div>