mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-22 14:15:28 +00:00
Compare commits
34 Commits
feat/googl
...
feat/db-fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb5543fb9b | ||
|
|
706e6cf712 | ||
|
|
c6decaa0d1 | ||
|
|
d193283b11 | ||
|
|
accfd540f0 | ||
|
|
9b1ef64618 | ||
|
|
c340088434 | ||
|
|
984c296725 | ||
|
|
9acfa08ea8 | ||
|
|
45cb95f6a8 | ||
|
|
8426bd5704 | ||
|
|
b9833db3bb | ||
|
|
b3572293ba | ||
|
|
c1752efe4b | ||
|
|
491d0020bd | ||
|
|
5c1182dc66 | ||
|
|
638b5ff1ca | ||
|
|
069ca11917 | ||
|
|
4675fa4623 | ||
|
|
af88d6462b | ||
|
|
ef6005e648 | ||
|
|
11875b4a1e | ||
|
|
22038f7779 | ||
|
|
de2cb07a15 | ||
|
|
ffc29b9c24 | ||
|
|
4860b9439c | ||
|
|
d10af13134 | ||
|
|
c98260efec | ||
|
|
a40b83c2b3 | ||
|
|
bfb76aa268 | ||
|
|
f8758d7b47 | ||
|
|
f51f89e35f | ||
|
|
26824eb3a8 | ||
|
|
d2024d31ba |
@@ -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
|
||||
|
||||
2
.github/workflows/build-and-push.yml
vendored
2
.github/workflows/build-and-push.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
20
Makefile
20
Makefile
@@ -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
|
||||
|
||||
|
||||
|
||||
42
README.md
42
README.md
@@ -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 isn’t 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**
|
||||
|
||||
@@ -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
92
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
191
scripts/i18n-check.sh
Executable 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
44
src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -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')}
|
||||
|
||||
104
src/lib/database/healthCheck.ts
Normal file
104
src/lib/database/healthCheck.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
264
src/lib/i18n/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
11
src/routes/+page.server.ts
Normal file
11
src/routes/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user