diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml index 8edaae1..2a29c40 100644 --- a/.forgejo/workflows/test.yml +++ b/.forgejo/workflows/test.yml @@ -21,16 +21,16 @@ jobs: playwright: runs-on: ubuntu-latest needs: vitest - services: - mongo: - image: mongo:7 - ports: - - 27017:27017 env: - MONGODB_URI: mongodb://localhost:27017/ghostguild-test + MONGODB_URI: mongodb://mongo-ci:27017/ghostguild-test JWT_SECRET: ci-test-jwt-secret + RESEND_API_KEY: re_ci_dummy_not_used + HELCIM_API_TOKEN: helcim_ci_dummy_not_used + OIDC_COOKIE_SECRET: ci-oidc-cookie-secret-not-secret NUXT_PUBLIC_COMING_SOON: 'false' NODE_ENV: development + ALLOW_DEV_TEST_ENDPOINTS: 'true' + BASE_URL: http://localhost:3000 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -39,15 +39,35 @@ jobs: cache: npm - run: npm ci - run: npx playwright install --with-deps chromium + - name: Start MongoDB + run: | + docker rm -f mongo-ci 2>/dev/null || true + docker run -d --name mongo-ci mongo:7 + # Forgejo runs each job inside its own container; attach Mongo to + # that container's network so MONGODB_URI=mongodb://mongo-ci:27017 + # resolves from inside the runner. + RUNNER_NET=$(docker inspect "$HOSTNAME" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | awk '{print $1}') + docker network connect "$RUNNER_NET" mongo-ci + docker ps + - name: Wait for MongoDB + run: timeout 30 sh -c 'until docker exec mongo-ci mongosh --quiet --eval "1" >/dev/null 2>&1; do sleep 1; done' + - name: MongoDB log on failure + if: failure() + run: docker logs mongo-ci || true + - name: Seed test data + run: node scripts/seed-all.js && node scripts/seed-tags.js - run: npm run build - name: Start server - run: node .output/server/index.mjs & + run: node .output/server/index.mjs > /tmp/server.log 2>&1 & env: PORT: 3000 - name: Wait for server run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done' - - run: npx playwright test --ignore-snapshots - - uses: actions/upload-artifact@v4 + - name: Server log on failure + if: failure() + run: cat /tmp/server.log || true + - run: npx playwright test + - uses: actions/upload-artifact@v3 if: failure() with: name: playwright-report @@ -68,39 +88,3 @@ jobs: -H 'Content-type: application/json' \ --data "{\"text\":\":x: *Ghost Guild CI failed* on \`${{ github.ref_name }}\`\nCommit: ${{ github.sha }}\n${{ github.server_url }}/${{ github.repository }}/actions\"}" - visual: - runs-on: ubuntu-latest - needs: vitest - continue-on-error: true - services: - mongo: - image: mongo:7 - ports: - - 27017:27017 - env: - MONGODB_URI: mongodb://localhost:27017/ghostguild-test - JWT_SECRET: ci-test-jwt-secret - NUXT_PUBLIC_COMING_SOON: 'false' - NODE_ENV: development - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - run: npm ci - - run: npx playwright install --with-deps chromium - - run: npm run build - - name: Start server - run: node .output/server/index.mjs & - env: - PORT: 3000 - - name: Wait for server - run: timeout 30 sh -c 'until curl -sf http://localhost:3000; do sleep 1; done' - - run: npx playwright test e2e/visual/ - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: visual-diffs - path: e2e/test-results/ - retention-days: 7 diff --git a/.husky/pre-push b/.husky/pre-push old mode 100644 new mode 100755 diff --git a/.serena/project.yml b/.serena/project.yml index 9d24cb3..0d43951 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,21 +3,26 @@ project_name: "ghostguild-org" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# swift systemverilog terraform toml typescript +# typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -65,53 +70,17 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -122,11 +91,14 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -150,3 +122,19 @@ read_only_memory_patterns: [] # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 4167651..9ee189f 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -27,7 +27,10 @@ --text: #2a2015; --text-bright: #1a1008; --text-dim: #5a5040; - --text-faint: #746a58; + /* Darkened from #746a58 (4.01:1 on --surface, fails WCAG AA) to #665c4b + (4.94:1 on --surface, 5.13:1 on --bg). Stays visually quieter than + --text-dim (5.80:1) while meeting AA for small text. */ + --text-faint: #665c4b; --parch: #2a2015; --parch-hover: #3a3025; --parch-text: #ede4d0; diff --git a/app/components/BoardPostCard.vue b/app/components/BoardPostCard.vue index 97067e9..a79a535 100644 --- a/app/components/BoardPostCard.vue +++ b/app/components/BoardPostCard.vue @@ -178,7 +178,8 @@ const slackLinks = computed(() => { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-faint); + /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ + color: var(--text-dim); } .post-actions { @@ -233,7 +234,8 @@ const slackLinks = computed(() => { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-faint); + /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ + color: var(--text-dim); margin-bottom: 2px; } .block-text { @@ -244,7 +246,8 @@ const slackLinks = computed(() => { .post-note { font-size: 11px; - color: var(--text-faint); + /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ + color: var(--text-dim); font-style: italic; margin: 8px 0; white-space: pre-wrap; @@ -293,7 +296,8 @@ const slackLinks = computed(() => { align-items: center; justify-content: center; font-size: 10px; - color: var(--text-faint); + /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ + color: var(--text-dim); font-family: "Commit Mono", monospace; } .author-name { @@ -308,7 +312,8 @@ const slackLinks = computed(() => { } .slack-handle { font-size: 11px; - color: var(--text-faint); + /* --text-faint fails WCAG AA (4.01:1) on the cream card bg */ + color: var(--text-dim); font-family: "Commit Mono", monospace; background: transparent; border: none; diff --git a/app/components/ColumnsLayout.vue b/app/components/ColumnsLayout.vue index 3ea07c4..2b170c0 100644 --- a/app/components/ColumnsLayout.vue +++ b/app/components/ColumnsLayout.vue @@ -29,13 +29,14 @@ const props = defineProps({ limit: { type: Number, default: 3 }, }) -const upcomingEvents = ref([]) +let upcomingEvents = ref([]) if (props.cols === 'events-sidebar') { const { data } = await useFetch('/api/events', { query: { upcoming: true, limit: props.limit }, default: () => [], + server: false, }) - upcomingEvents.value = data.value || [] + upcomingEvents = computed(() => data.value || []) } diff --git a/app/components/EventTicketPurchase.vue b/app/components/EventTicketPurchase.vue index e78652e..eede73d 100644 --- a/app/components/EventTicketPurchase.vue +++ b/app/components/EventTicketPurchase.vue @@ -38,14 +38,14 @@
-
Registration

You're Registered!

@@ -73,176 +42,197 @@ import * as chrono from "chrono-node"; const props = defineProps({ - modelValue: { - type: String, - default: "", - }, + modelValue: { type: String, default: "" }, placeholder: { type: String, - default: 'e.g., "tomorrow at 3pm", "next Friday at 9am", "in 2 hours"', - }, - inputClass: { - type: [String, Object], - default: "", - }, - required: { - type: Boolean, - default: false, + default: 'e.g., "tomorrow at 3pm", "Nov 22 at 3pm"', }, + displayTimezone: { type: String, default: "" }, + required: { type: Boolean, default: false }, }); const emit = defineEmits(["update:modelValue"]); -const naturalInput = ref(""); -const parsedDate = ref(null); -const isValidParse = ref(false); +const rawInput = ref(""); +const isValid = ref(false); const hasError = ref(false); const errorMessage = ref(""); -const datetimeValue = ref(""); +// previewDate holds the parsed value as a UTC Date so we can format it in +// arbitrary timezones without re-parsing. Source of truth for the preview. +const previewDate = ref(null); -// Initialize with current value -onMounted(() => { - if (props.modelValue) { - const date = new Date(props.modelValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - } - } +const trailingState = computed(() => { + if (!rawInput.value.trim()) return undefined; + if (hasError.value) return "error"; + if (isValid.value) return "success"; + return undefined; }); -// Watch for external changes to modelValue -watch( - () => props.modelValue, - (newValue) => { - if (newValue && newValue !== formatForDatetimeLocal(parsedDate.value)) { - const date = new Date(newValue); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - datetimeValue.value = formatForDatetimeLocal(date); - isValidParse.value = true; - naturalInput.value = ""; // Clear natural input when set externally - } - } else if (!newValue) { - reset(); - } - }, -); - -const parseNaturalInput = () => { - const input = naturalInput.value.trim(); - - if (!input) { - reset(); - return; - } - - try { - // Parse with chrono-node - const results = chrono.parse(input); - - if (results.length > 0) { - const result = results[0]; - const date = result.date(); - - // Validate the parsed date - if (date && !isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - datetimeValue.value = formatForDatetimeLocal(date); - emit("update:modelValue", formatForDatetimeLocal(date)); - } else { - setError("Could not parse this date format"); - } - } else { - setError( - 'Could not understand this date format. Try something like "tomorrow at 3pm" or "next Friday"', - ); - } - } catch (error) { - setError("Error parsing date"); - } -}; - -const onBlur = () => { - // If we have a valid parse but the input changed, try to parse again - if (naturalInput.value.trim() && !isValidParse.value) { - parseNaturalInput(); - } -}; - -const onDatetimeChange = () => { - if (datetimeValue.value) { - const date = new Date(datetimeValue.value); - if (!isNaN(date.getTime())) { - parsedDate.value = date; - isValidParse.value = true; - hasError.value = false; - naturalInput.value = ""; // Clear natural input when using traditional picker - emit("update:modelValue", datetimeValue.value); - } - } else { - reset(); - } -}; - -const reset = () => { - parsedDate.value = null; - isValidParse.value = false; - hasError.value = false; - errorMessage.value = ""; - emit("update:modelValue", ""); -}; - -const setError = (message) => { - isValidParse.value = false; - hasError.value = true; - errorMessage.value = message; - parsedDate.value = null; -}; - -const formatForDatetimeLocal = (date) => { - if (!date) return ""; - // Format as YYYY-MM-DDTHH:MM for datetime-local input - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -const formatParsedDate = (date) => { - if (!date) return ""; - - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = date.toDateString() === tomorrow.toDateString(); - - const timeStr = date.toLocaleString("en-US", { +const previewText = computed(() => { + if (!previewDate.value) return ""; + const tz = activeTZ(); + const date = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", hour: "numeric", minute: "2-digit", hour12: true, - }); + }).format(previewDate.value); + const abbr = shortTimezoneName(previewDate.value, tz); + return abbr ? `${date} ${abbr}` : date; +}); - if (isToday) { - return `Today at ${timeStr}`; - } else if (isTomorrow) { - return `Tomorrow at ${timeStr}`; - } else { - return date.toLocaleString("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - hour12: true, - }); +const activeTZ = () => + props.displayTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + +// Seed the input from modelValue without triggering chrono. The parent's +// value is canonical — we just render it as a chrono-friendly readable +// string so the user can backspace and tweak in place. +const seedFromModelValue = () => { + if (!props.modelValue) { + rawInput.value = ""; + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + return; } + const tz = activeTZ(); + const utc = zonedLocalToUTC(props.modelValue, tz); + if (!utc) return; + previewDate.value = utc; + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + rawInput.value = readableSeed(utc, tz); +}; + +onMounted(seedFromModelValue); + +watch( + () => props.modelValue, + (next) => { + const tz = activeTZ(); + const expected = previewDate.value + ? utcToZonedLocal(previewDate.value, tz) + : ""; + if (next === expected) return; + seedFromModelValue(); + }, +); + +watch( + () => props.displayTimezone, + () => { + // Re-interpret the current input under the new TZ so the preview and + // emitted value reflect the new timezone semantics. + if (rawInput.value.trim()) parse(rawInput.value); + }, +); + +const onInputChange = (value) => { + rawInput.value = value; + parse(value); +}; + +const parse = (input) => { + const trimmed = input.trim(); + if (!trimmed) { + isValid.value = false; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = null; + emit("update:modelValue", ""); + return; + } + const tz = activeTZ(); + let results; + try { + results = chrono.parse(trimmed, referenceNowInTZ(tz)); + } catch { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + if (!results.length) { + setError("Couldn't read that — try \"tomorrow 3pm\" or \"Nov 22 3pm\""); + return; + } + const date = results[0].date(); + if (!date || Number.isNaN(date.getTime())) { + setError("Couldn't read that date"); + return; + } + // chrono returned a Date whose browser-local components match what the + // user typed in the event timezone (because we shifted the reference). + // Read those components as wall-clock in displayTimezone. + const localStr = browserComponentsToString(date); + const utc = zonedLocalToUTC(localStr, tz); + if (!utc) { + setError("Couldn't parse this date"); + return; + } + isValid.value = true; + hasError.value = false; + errorMessage.value = ""; + previewDate.value = utc; + emit("update:modelValue", localStr); +}; + +const setError = (msg) => { + isValid.value = false; + hasError.value = true; + errorMessage.value = msg; + previewDate.value = null; + emit("update:modelValue", ""); +}; + +// Build a Date object whose browser-local components equal the current +// wall-clock in the given IANA timezone, so chrono's "tomorrow"/"next +// Friday" anchor to the event TZ rather than the editor's browser TZ. +const referenceNowInTZ = (tz) => { + const nowStr = utcToZonedLocal(new Date(), tz); + if (!nowStr) return new Date(); + const [d, t] = nowStr.split("T"); + const [y, mo, day] = d.split("-").map(Number); + const [h, mi] = t.split(":").map(Number); + return new Date(y, mo - 1, day, h, mi); +}; + +const browserComponentsToString = (date) => { + const y = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const h = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + return `${y}-${mo}-${d}T${h}:${mi}`; +}; + +const readableSeed = (utc, tz) => { + // Format chosen to round-trip cleanly through chrono.parse. + return new Intl.DateTimeFormat("en-US", { + timeZone: tz, + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }).format(utc); }; + + diff --git a/app/components/TopStrip.vue b/app/components/TopStrip.vue index bbf5f28..37a8aec 100644 --- a/app/components/TopStrip.vue +++ b/app/components/TopStrip.vue @@ -12,7 +12,12 @@ class="breadcrumb-link" >{{ crumb.label }} - {{ crumb.label }} + + {{ crumb.label }} + + diff --git a/app/composables/useEventDateUtils.js b/app/composables/useEventDateUtils.js index ab536b4..2aec85e 100644 --- a/app/composables/useEventDateUtils.js +++ b/app/composables/useEventDateUtils.js @@ -1,85 +1,98 @@ -// Utility composable for event date handling with timezone support +// Utility composable for event date handling with timezone support. +// Pass `{ timeZone: event.displayTimezone }` to render in the event's TZ. export const useEventDateUtils = () => { - const TIMEZONE = "America/Toronto"; + const DEFAULT_TIMEZONE = "America/Toronto"; - // Format a date to a specific format const formatDate = (date, options = {}) => { + if (!date) return ""; const dateObj = date instanceof Date ? date : new Date(date); - const { month = "short", day = "numeric", year = "numeric" } = options; + if (isNaN(dateObj.getTime())) return ""; + const { + month = "short", + day = "numeric", + year = "numeric", + weekday, + timeZone, + } = options; return new Intl.DateTimeFormat("en-US", { + ...(weekday && { weekday }), month, day, year, + ...(timeZone && { timeZone }), }).format(dateObj); }; - // Format event date range - const formatDateRange = (startDate, endDate, compact = false) => { + const formatDateRange = (startDate, endDate, compact = false, timeZone) => { if (!startDate || !endDate) return "No dates"; const start = new Date(startDate); const end = new Date(endDate); - const startMonth = start.toLocaleDateString("en-US", { month: "short" }); - const endMonth = end.toLocaleDateString("en-US", { month: "short" }); - const startDay = start.getDate(); - const endDay = end.getDate(); - const year = end.getFullYear(); + const tzOpts = timeZone ? { timeZone } : {}; + const startMonth = start.toLocaleDateString("en-US", { month: "short", ...tzOpts }); + const endMonth = end.toLocaleDateString("en-US", { month: "short", ...tzOpts }); + const startDay = Number( + start.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }), + ); + const endDay = Number( + end.toLocaleDateString("en-US", { day: "numeric", ...tzOpts }), + ); + const year = Number( + end.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }), + ); + const startMonthIdx = startMonth; // compared as label string + const endMonthIdx = endMonth; + const startYear = Number( + start.toLocaleDateString("en-US", { year: "numeric", ...tzOpts }), + ); if (compact) { - if ( - start.getMonth() === end.getMonth() && - start.getFullYear() === end.getFullYear() - ) { + if (startMonthIdx === endMonthIdx && startYear === year) { return `${startMonth} ${startDay}-${endDay}`; } return `${startMonth} ${startDay} - ${endMonth} ${endDay}`; } - if ( - start.getMonth() === end.getMonth() && - start.getFullYear() === end.getFullYear() - ) { + if (startMonthIdx === endMonthIdx && startYear === year) { return `${startMonth} ${startDay}-${endDay}, ${year}`; - } else if (start.getFullYear() === end.getFullYear()) { + } else if (startYear === year) { return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`; } else { - return `${formatDate(startDate)} - ${formatDate(endDate)}`; + return `${formatDate(startDate, { timeZone })} - ${formatDate(endDate, { timeZone })}`; } }; - // Check if a date is in the past const isPastDate = (date) => { const dateObj = date instanceof Date ? date : new Date(date); - const now = new Date(); - return dateObj < now; + return dateObj < new Date(); }; - // Check if a date is today - const isToday = (date) => { + const isToday = (date, timeZone) => { const dateObj = date instanceof Date ? date : new Date(date); const today = new Date(); + const opts = { year: "numeric", month: "2-digit", day: "2-digit", ...(timeZone && { timeZone }) }; return ( - dateObj.getDate() === today.getDate() && - dateObj.getMonth() === today.getMonth() && - dateObj.getFullYear() === today.getFullYear() + dateObj.toLocaleDateString("en-US", opts) === + today.toLocaleDateString("en-US", opts) ); }; - // Get a readable time string - const formatTime = (date, includeSeconds = false) => { + const formatTime = (date, includeSeconds = false, timeZone) => { const dateObj = date instanceof Date ? date : new Date(date); - const options = { + return new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", ...(includeSeconds && { second: "2-digit" }), - }; - return new Intl.DateTimeFormat("en-US", options).format(dateObj); + ...(timeZone && { timeZone }), + }).format(dateObj); }; return { - TIMEZONE, + DEFAULT_TIMEZONE, + // Legacy alias for callers that hard-coded the constant. + TIMEZONE: DEFAULT_TIMEZONE, formatDate, formatDateRange, isPastDate, diff --git a/app/composables/useSiteMeta.js b/app/composables/useSiteMeta.js new file mode 100644 index 0000000..007a644 --- /dev/null +++ b/app/composables/useSiteMeta.js @@ -0,0 +1,58 @@ +/** + * useSiteMeta — set page-level SEO + social meta with site defaults baked in. + * + * Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url + * resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image, + * og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow". + * + * Pass a function (or refs in fields) to keep tags reactive when content loads + * asynchronously via useFetch. + */ +export function useSiteMeta(input) { + const runtimeConfig = useRuntimeConfig() + const route = useRoute() + const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '') + + const resolve = () => (typeof input === 'function' ? input() : input) || {} + + const buildAbsolute = (path) => { + if (!path) return undefined + if (/^https?:\/\//i.test(path)) return path + return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}` + } + + const titleGetter = () => resolve().title || 'Ghost Guild' + const descGetter = () => resolve().description || undefined + const isBareTitle = () => Boolean(resolve().bareTitle) + const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png') + const typeGetter = () => resolve().type || 'website' + const robotsGetter = () => + resolve().noindex ? 'noindex, nofollow' : undefined + const canonicalGetter = () => buildAbsolute(route.path) + + useSeoMeta({ + title: titleGetter, + description: descGetter, + ogSiteName: 'Ghost Guild', + ogTitle: titleGetter, + ogDescription: descGetter, + ogType: typeGetter, + ogUrl: canonicalGetter, + ogImage: imageGetter, + ogImageWidth: 1200, + ogImageHeight: 630, + twitterCard: 'summary_large_image', + twitterTitle: titleGetter, + twitterDescription: descGetter, + twitterImage: imageGetter, + robots: robotsGetter, + }) + + useHead({ + link: [{ rel: 'canonical', href: canonicalGetter }], + }) + + if (isBareTitle()) { + useHead({ titleTemplate: null }) + } +} diff --git a/app/config/eventTypes.js b/app/config/eventTypes.js new file mode 100644 index 0000000..e256338 --- /dev/null +++ b/app/config/eventTypes.js @@ -0,0 +1,21 @@ +// Central configuration for Ghost Guild event types. +// Keep values in sync with the `eventType` enum in server/models/event.js. +export const EVENT_TYPES = [ + { value: "talk", label: "Talk / Presentation" }, + { value: "workshop", label: "Workshop" }, + { value: "community-meetup", label: "Community Meetup" }, + { value: "coworking", label: "Co-working Session" }, + { value: "peer-session", label: "Peer Session" }, + { value: "skills-share", label: "Skills Share" }, + { value: "info-session", label: "Info Session" }, +]; + +export const EVENT_TYPE_VALUES = EVENT_TYPES.map((t) => t.value); + +const labelLookup = Object.fromEntries( + EVENT_TYPES.map((t) => [t.value, t.label]), +); + +export function eventTypeLabel(value) { + return labelLookup[value] || value || ""; +} diff --git a/app/config/memberStatus.js b/app/config/memberStatus.js new file mode 100644 index 0000000..04850e8 --- /dev/null +++ b/app/config/memberStatus.js @@ -0,0 +1,8 @@ +export const STATUS_LABELS = { + active: "Active", + pending_payment: "Payment setup incomplete", + suspended: "Paused", + cancelled: "Closed", +}; + +export const statusLabel = (s) => STATUS_LABELS[s] || "Pending"; diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 5e0baad..0c0d201 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -217,6 +217,8 @@ + +` diff --git a/app/pages/events/[slug].vue b/app/pages/events/[slug].vue index 7f72a67..6a5ac57 100644 --- a/app/pages/events/[slug].vue +++ b/app/pages/events/[slug].vue @@ -22,15 +22,14 @@

Location - {{ event.location }} + + Platform TBD + +
-
- Capacity - {{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats -
@@ -82,7 +81,7 @@

About This Event

-

{{ event.description }}

+
@@ -91,17 +90,23 @@ class="section" >

About the {{ event.series.title }} Series

-

{{ event.series.description }}

+
+
+ + +
+

Additional Information

+

Agenda

-
    +
    • {{ item }}
    • -
+
@@ -128,6 +133,7 @@ :event-id="event._id || event.id" :event-start-date="event.startDate" :event-title="event.title" + :event-timezone="eventTimeZone" :user-email="memberData?.email" :user-name="memberData?.name" @success="handleTicketSuccess" @@ -139,7 +145,7 @@
Event Details
Type - {{ event.eventType }} + {{ eventTypeLabel(event.eventType) }}
Members only @@ -165,6 +171,8 @@ @@ -337,12 +350,79 @@ useHead(() => ({ margin-bottom: 8px; } .section p { - font-size: 12px; + font-size: 14px; color: var(--text-dim); line-height: 1.7; max-width: 560px; } +.prose { + font-size: 14px; + color: var(--text-dim); + line-height: 1.7; + max-width: 560px; +} +.prose :deep(p) { + margin-bottom: 12px; +} +.prose :deep(p:last-child) { + margin-bottom: 0; +} +.prose :deep(a) { + color: var(--ember); + text-decoration: underline; + text-underline-offset: 2px; +} +.prose :deep(strong) { + color: var(--text-bright); +} +.prose :deep(ul), +.prose :deep(ol) { + list-style: none; + padding: 0; + margin: 8px 0 12px; +} +.prose :deep(ul li), +.prose :deep(ol li) { + position: relative; + padding: 2px 0 2px 16px; + margin-bottom: 4px; +} +.prose :deep(ul li::before) { + content: "›"; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); + font-size: 14px; + line-height: 1.7; +} +.prose :deep(ol) { + counter-reset: prose-item; +} +.prose :deep(ol li) { + counter-increment: prose-item; + padding-left: 28px; +} +.prose :deep(ol li::before) { + content: counter(prose-item) "."; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); +} +.prose :deep(blockquote) { + border-left: 2px solid var(--candle-faint); + padding-left: 12px; + margin: 12px 0; + color: var(--text-faint); +} +.prose :deep(code) { + font-family: "Commit Mono", monospace; + background: var(--input-bg); + padding: 0 4px; +} + .circle-badges { display: flex; gap: 6px; @@ -355,10 +435,27 @@ useHead(() => ({ } .agenda-list { - padding-left: 20px; - font-size: 12px; + list-style: none; + padding: 0; + margin: 8px 0 0; + font-size: 14px; color: var(--text-dim); - line-height: 2; + line-height: 1.7; + max-width: 560px; +} +.agenda-list li { + position: relative; + padding: 2px 0 2px 16px; + margin-bottom: 4px; +} +.agenda-list li::before { + content: "›"; + position: absolute; + left: 0; + top: 2px; + color: var(--candle-faint); + font-size: 14px; + line-height: 1.7; } .speaker { diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 3a64189..da0ce91 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -34,8 +34,8 @@ :class="{ 'is-cancelled': event.isCancelled }" >
- {{ formatDate(event.startDate) }} - {{ formatTime(event.startDate) }} + {{ formatDate(event) }} + {{ formatTime(event) }}
@@ -45,34 +45,21 @@ cancelled + Registered
{{ event.tagline }}
{{ - event.eventType + eventTypeLabel(event.eventType) }} · {{ formatLocation(event) }}
- - - -
Members @@ -119,15 +106,20 @@