Compare commits
30 Commits
main
...
v0.7.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c56d77a23
|
|||
|
eaae9b7487
|
|||
|
f012025917
|
|||
|
ac65cd683a
|
|||
|
e689e3efd8
|
|||
|
94d3a2716e
|
|||
|
18d9f998f5
|
|||
|
bb3042014e
|
|||
|
6e1f58cd8e
|
|||
|
cc6903a799
|
|||
|
6c4d047bf0
|
|||
|
2874ea3be1
|
|||
|
26bc33a095
|
|||
|
67c7a3c050
|
|||
|
5d08b1c927
|
|||
|
148b8ff49d
|
|||
|
c4113f63a4
|
|||
|
6274e4936d
|
|||
|
e1259688a4
|
|||
|
e2a4dd851d
|
|||
|
2a61cc105f
|
|||
|
d6f58ddabd
|
|||
|
a1d9e147f9
|
|||
|
92bfc7bafa
|
|||
|
6f61edd659
|
|||
|
ea4e848e57
|
|||
|
c08fa6c2d8
|
|||
|
01aae9e8ff
|
|||
|
70c6837858
|
|||
|
6167a713dd
|
8
.claude/commands/handoff.md
Normal file
@@ -0,0 +1,8 @@
|
||||
Write a session handoff file for the current session.
|
||||
|
||||
Steps:
|
||||
1. Read `templates/claude-templates.md` and find the Session Handoff template (Template 4). Use the Light Handoff if this is a small project (under 5 sessions), Full Handoff otherwise.
|
||||
2. Fill in every field based on what was accomplished in this session. Be specific — include exact file paths for every output, exact numbers discovered, and conditional logic established.
|
||||
3. Write the handoff to `./docs/summaries/handoff-[today's date]-[topic].md`.
|
||||
4. If a previous handoff file exists in `./docs/summaries/`, move it to `./docs/archive/handoffs/`.
|
||||
5. Tell me the file path of the new handoff and summarize what it contains.
|
||||
13
.claude/commands/process-doc.md
Normal file
@@ -0,0 +1,13 @@
|
||||
Process an input document into a structured source summary.
|
||||
|
||||
Steps:
|
||||
1. Read `templates/claude-templates.md` and find the Source Document Summary template (Template 1). Use the Light Source Summary if this is a small project (under 5 sessions), Full Source Summary otherwise.
|
||||
2. Read the document at: $ARGUMENTS
|
||||
3. Extract all information into the template format. Pay special attention to:
|
||||
- EXACT numbers — do not round or paraphrase
|
||||
- Requirements in IF/THEN/BUT/EXCEPT format
|
||||
- Decisions with rationale and rejected alternatives
|
||||
- Open questions marked as OPEN, ASSUMED, or MISSING
|
||||
4. Write the summary to `./docs/summaries/source-[filename].md`.
|
||||
5. Move the original document to `./docs/archive/`.
|
||||
6. Tell me: what was extracted, what's unclear, and what needs follow-up.
|
||||
13
.claude/commands/status.md
Normal file
@@ -0,0 +1,13 @@
|
||||
Report on the current project state.
|
||||
|
||||
Steps:
|
||||
1. Read `./docs/summaries/00-project-brief.md` for project context.
|
||||
2. Find and read the latest `handoff-*.md` file in `./docs/summaries/` for current state.
|
||||
3. List all files in `./docs/summaries/` to understand what's been processed.
|
||||
4. Report:
|
||||
- **Project:** name and type from the project brief
|
||||
- **Current phase:** based on the project phase tracker
|
||||
- **Last session:** what was accomplished (from the latest handoff)
|
||||
- **Next steps:** what the next session should do (from the latest handoff)
|
||||
- **Open questions:** anything unresolved
|
||||
- **Summary file count:** how many files in docs/summaries/ (warn if approaching 15)
|
||||
@@ -1,8 +0,0 @@
|
||||
/dist
|
||||
/src-capacitor
|
||||
/src-cordova
|
||||
/.quasar
|
||||
/node_modules
|
||||
.eslintrc.js
|
||||
/src-ssr
|
||||
/quasar.config.*.temporary.compiled*
|
||||
@@ -1,90 +0,0 @@
|
||||
module.exports = {
|
||||
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
|
||||
// This option interrupts the configuration hierarchy at this file
|
||||
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
|
||||
root: true,
|
||||
|
||||
// https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser
|
||||
// Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working
|
||||
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
|
||||
parserOptions: {
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
extraFileExtensions: [ '.vue' ]
|
||||
},
|
||||
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
'vue/setup-compiler-macros': true
|
||||
},
|
||||
|
||||
// Rules order is important, please avoid shuffling them
|
||||
extends: [
|
||||
// Base ESLint recommended rules
|
||||
// 'eslint:recommended',
|
||||
|
||||
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
|
||||
// ESLint typescript rules
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
|
||||
// Uncomment any of the lines below to choose desired strictness,
|
||||
// but leave only one uncommented!
|
||||
// See https://eslint.vuejs.org/rules/#available-rules
|
||||
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
|
||||
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
|
||||
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
|
||||
|
||||
// https://github.com/prettier/eslint-config-prettier#installation
|
||||
// usage with Prettier, provided by 'eslint-config-prettier'.
|
||||
'prettier'
|
||||
],
|
||||
|
||||
plugins: [
|
||||
// required to apply rules which need type information
|
||||
'@typescript-eslint',
|
||||
|
||||
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
|
||||
// required to lint *.vue files
|
||||
'vue'
|
||||
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
|
||||
// Prettier has not been included as plugin to avoid performance impact
|
||||
// add it as an extension for your IDE
|
||||
|
||||
],
|
||||
|
||||
globals: {
|
||||
ga: 'readonly', // Google Analytics
|
||||
cordova: 'readonly',
|
||||
__statics: 'readonly',
|
||||
__QUASAR_SSR__: 'readonly',
|
||||
__QUASAR_SSR_SERVER__: 'readonly',
|
||||
__QUASAR_SSR_CLIENT__: 'readonly',
|
||||
__QUASAR_SSR_PWA__: 'readonly',
|
||||
process: 'readonly',
|
||||
Capacitor: 'readonly',
|
||||
chrome: 'readonly'
|
||||
},
|
||||
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
|
||||
quotes: ['warn', 'single', { avoidEscape: true }],
|
||||
|
||||
// this rule, if on, would require explicit return type on the `render` function
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
|
||||
// in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
|
||||
// The core 'no-unused-vars' rules (in the eslint:recommended ruleset)
|
||||
// does not work with type definitions
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
// allow debugger during development only
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,62 @@
|
||||
name: Build BAB Application Deployment Artifact
|
||||
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- alpha
|
||||
- devel
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- name: Install yarn
|
||||
run: npm install --global yarn
|
||||
- name: Install yarn dependencies
|
||||
run: yarn install
|
||||
- name: Install Quasar CLI
|
||||
run: yarn global add @quasar/cli
|
||||
- name: Temporary - Invoke custom qcalendar build
|
||||
run: quasar ext invoke @quasar/qcalendar
|
||||
node-version: "20"
|
||||
|
||||
- name: Enable Corepack and Yarn
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare yarn@stable --activate
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-modules-
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Create env file
|
||||
run: |
|
||||
echo "${{ vars.ENV_FILE }}" > .env.local
|
||||
run: echo "${{ vars.ENV_FILE }}" > .env
|
||||
|
||||
- name: Show env file
|
||||
run: |
|
||||
/bin/cat .env.local
|
||||
- name: Build Project
|
||||
run: quasar build -m pwa
|
||||
- name: Get Version Number
|
||||
id: get_version
|
||||
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
|
||||
- name: Tarfile
|
||||
run: |
|
||||
cd dist/pwa
|
||||
tar czf ../../build-${{ steps.get_version.outputs.VERSION }}.tar.gz .
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}
|
||||
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz
|
||||
run: cat .env
|
||||
|
||||
- name: Build and Release
|
||||
id: build
|
||||
run: yarn semantic-release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GT_TOKEN }}
|
||||
GITEA_URL: ${{ vars.GT_URL }}
|
||||
|
||||
- name: Trigger Ansible Deploy Playbook
|
||||
uses: https://github.com/distributhor/workflow-webhook@v3
|
||||
with:
|
||||
webhook_url: ${{ vars.WEBHOOK_URL }}
|
||||
verbose: true
|
||||
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id}}/artifacts/build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}" }'
|
||||
if: steps.build.outputs.VERSION != ''
|
||||
run: |
|
||||
curl --fail-with-body -sS \
|
||||
-H "Authorization: Bearer ${{ secrets.WEBHOOK_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"artifact_url":"${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/v${{ steps.build.outputs.VERSION }}/release-${{ steps.build.outputs.VERSION }}.tar.gz"}' \
|
||||
"${{ vars.WEBHOOK_URL }}"
|
||||
|
||||
60
.gitignore
vendored
@@ -1,40 +1,40 @@
|
||||
.DS_Store
|
||||
.thumbs.db
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Quasar core related directories
|
||||
.quasar
|
||||
/dist
|
||||
/quasar.config.*.temporary.compiled*
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Cordova related directories and files
|
||||
/src-cordova/node_modules
|
||||
/src-cordova/platforms
|
||||
/src-cordova/plugins
|
||||
/src-cordova/www
|
||||
|
||||
# Capacitor related directories and files
|
||||
/src-capacitor/www
|
||||
/src-capacitor/node_modules
|
||||
|
||||
# BEX related directories and files
|
||||
/src-bex/www
|
||||
/src-bex/js/core
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
# Misc
|
||||
.DS_Store
|
||||
.thumbs.db
|
||||
.fleet
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# local .env files
|
||||
.env*
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# version file
|
||||
src/version.js
|
||||
# Yarn 4
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
|
||||
# Release artifacts
|
||||
release-*.gz
|
||||
CHANGELOG.md
|
||||
VERSION
|
||||
|
||||
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
yarn typecheck
|
||||
3
.npmrc
@@ -1,3 +0,0 @@
|
||||
# pnpm-related options
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
@@ -3,20 +3,24 @@
|
||||
"main",
|
||||
"next",
|
||||
{ "name": "beta", "prerelease": true },
|
||||
{ "name": "devel", "prerelease": true },
|
||||
{ "name": "alpha", "prerelease": true }
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "node generate-version.cjs '${nextRelease.version}' && yarn install --immutable && yarn generate",
|
||||
"publishCmd": "tar -czvf release-${nextRelease.version}.tar.gz -C .output/public . && echo \"VERSION=${nextRelease.version}\" >> \"$GITHUB_OUTPUT\""
|
||||
}
|
||||
],
|
||||
[
|
||||
"@saithodev/semantic-release-gitea",
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"path": "dist/build-*.tar.gz",
|
||||
"label": "package distribution"
|
||||
}
|
||||
]
|
||||
"assets": ["release-${nextRelease.version}.tar.gz"]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
3
.yarnrc.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
51
CLAUDE.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Session Start
|
||||
|
||||
Read the latest handoff in docs/summaries/ if one exists. Load only the files that handoff references — not all summaries. If no handoff exists, ask: what is the project, what type of work, what is the target deliverable.
|
||||
|
||||
Before starting work, state: what you understand the project state to be, what you plan to do this session, and any open questions.
|
||||
|
||||
## Identity
|
||||
|
||||
You work with Patrick, a Solutions Architect, on the OYS Borrow a Boat (bab-app) project — a Quasar/Vue 3 app for managing a Borrow a Boat program for a Yacht Club. Backend is Appwrite.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **App**: OYS Borrow a Boat (oys_bab)
|
||||
- **Stack**: Quasar (Vue 3), TypeScript, Appwrite (BaaS)
|
||||
- **Purpose**: Manage a Borrow a Boat program for a Yacht Club
|
||||
- **Docs**: docs/planning/ contains personas, user/role/permission model, and time-based logic
|
||||
|
||||
## Rules
|
||||
|
||||
1. Do not mix unrelated project contexts in one session.
|
||||
2. Write state to disk, not conversation. After completing meaningful work, write a summary to docs/summaries/ using templates from templates/claude-templates.md. Include: decisions with rationale, exact numbers, file paths, open items.
|
||||
3. Before compaction or session end, write to disk: every number, every decision with rationale, every open question, every file path, exact next action.
|
||||
4. When switching work types (research → writing → review), write a handoff to docs/summaries/handoff-[date]-[topic].md and suggest a new session.
|
||||
5. Do not silently resolve open questions. Mark them OPEN or ASSUMED.
|
||||
6. Do not bulk-read documents. Process one at a time: read, summarize to disk, release from context before reading next. For the detailed protocol, read docs/context/processing-protocol.md.
|
||||
7. Sub-agent returns must be structured, not free-form prose. Use output contracts from templates/claude-templates.md.
|
||||
|
||||
## Where Things Live
|
||||
|
||||
- templates/claude-templates.md — summary, handoff, decision, analysis, task, output contract templates (read on demand)
|
||||
- docs/summaries/ — active session state (latest handoff + project brief + decision records + source summaries)
|
||||
- docs/context/ — reusable domain knowledge, loaded only when relevant to the current task
|
||||
- processing-protocol.md — full document processing steps
|
||||
- archive-rules.md — summary lifecycle and file archival rules
|
||||
- subagent-rules.md — rules for structured sub-agent outputs
|
||||
- docs/planning/ — original planning documents (personas, roles/permissions, time logic)
|
||||
- docs/archive/ — processed raw files. Do not read unless explicitly told.
|
||||
- output/deliverables/ — final outputs
|
||||
- src/ — Quasar/Vue app source
|
||||
- src-pwa/ — PWA config
|
||||
- appwrite.json — Appwrite project config
|
||||
|
||||
## Error Recovery
|
||||
|
||||
If context degrades or auto-compact fires unexpectedly: write current state to docs/summaries/recovery-[date].md, tell the user what may have been lost, suggest a fresh session.
|
||||
|
||||
## Before Delivering Output
|
||||
|
||||
Verify: exact numbers preserved, open questions marked OPEN, output matches what was requested (not assumed), claims backed by specific data, output consistent with stored decisions in docs/context/, summary written to disk for this session's work.
|
||||
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1,141 +1,13 @@
|
||||
<template>
|
||||
<div class="q-pa-xs row q-gutter-xs">
|
||||
<q-card
|
||||
flat
|
||||
class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||
<q-card-section>
|
||||
<div class="text-h5 q-mt-none q-mb-xs">
|
||||
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||
</q-card-section>
|
||||
<q-list class="q-px-xs">
|
||||
<q-item
|
||||
class="q-pa-none"
|
||||
clickable
|
||||
@click="boatSelect = true">
|
||||
<q-card
|
||||
v-if="boat"
|
||||
class="col-12">
|
||||
<q-card-section>
|
||||
<q-img
|
||||
:src="boat.imgSrc"
|
||||
:fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h7 text-left">
|
||||
{{ boat.name }}
|
||||
</div>
|
||||
<div class="col text-right text-caption">
|
||||
{{ boat.class }}
|
||||
</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="col-9">
|
||||
<q-list
|
||||
dense
|
||||
class="row">
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge
|
||||
color="primary"
|
||||
label="Start" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.interval?.start) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge
|
||||
color="primary"
|
||||
label="End" />
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
class="text-body2"
|
||||
style="min-width: 150px">
|
||||
{{ formatDate(bookingForm.interval?.end) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-separator vertical />
|
||||
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||
{{ bookingDuration.hours }} hours
|
||||
<div v-if="bookingDuration.minutes">
|
||||
<q-separator />
|
||||
{{ bookingDuration.minutes }} mins
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div
|
||||
v-else
|
||||
class="col-12">
|
||||
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||
</div>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-select
|
||||
filled
|
||||
v-model="bookingForm.reason"
|
||||
:options="reason_options"
|
||||
label="Reason for sail" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-input
|
||||
v-model="bookingForm.comment"
|
||||
clearable
|
||||
autogrow
|
||||
filled
|
||||
label="Additional Comments (optional)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Delete"
|
||||
color="negative"
|
||||
size="lg"
|
||||
v-if="reservation?.$id"
|
||||
@click="onDelete" />
|
||||
<q-btn
|
||||
label="Reset"
|
||||
@click="onReset"
|
||||
size="lg"
|
||||
color="secondary" />
|
||||
<q-btn
|
||||
label="Submit"
|
||||
@click="onSubmit"
|
||||
size="lg"
|
||||
color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog
|
||||
v-model="boatSelect"
|
||||
full-width>
|
||||
<BoatScheduleTableComponent
|
||||
:model-value="bookingForm.interval"
|
||||
@update:model-value="updateInterval" />
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { formatDate } from 'src/utils/schedule';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import BoatScheduleTableComponent from '~/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { formatDate } from '~/utils/schedule';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface BookingForm {
|
||||
$id?: string;
|
||||
@@ -170,7 +42,7 @@ watch(reservation, (newReservation) => {
|
||||
if (!newReservation) {
|
||||
bookingForm.value = newForm;
|
||||
} else {
|
||||
const updatedReservation = {
|
||||
bookingForm.value = {
|
||||
...newReservation,
|
||||
user: auth.currentUser?.$id,
|
||||
interval: {
|
||||
@@ -179,11 +51,10 @@ watch(reservation, (newReservation) => {
|
||||
resource: newReservation.resource,
|
||||
},
|
||||
};
|
||||
bookingForm.value = updatedReservation;
|
||||
}
|
||||
});
|
||||
|
||||
const updateInterval = (interval: Interval | null) => {
|
||||
const updateInterval = (interval: Interval | null | undefined) => {
|
||||
bookingForm.value.interval = interval;
|
||||
boatSelect.value = false;
|
||||
};
|
||||
@@ -195,14 +66,12 @@ const bookingDuration = computed((): { hours: number; minutes: number } => {
|
||||
const delta = Math.abs(end - start) / 1000;
|
||||
const hours = Math.floor(delta / 3600) % 24;
|
||||
const minutes = Math.floor(delta - hours * 3600) % 60;
|
||||
return { hours: hours, minutes: minutes };
|
||||
return { hours, minutes };
|
||||
}
|
||||
return { hours: 0, minutes: 0 };
|
||||
});
|
||||
|
||||
const bookingName = computed(() =>
|
||||
auth.getUserNameById(bookingForm.value?.user)
|
||||
);
|
||||
const bookingName = computed(() => auth.getUserNameById(bookingForm.value?.user));
|
||||
|
||||
const boat = computed((): Boat | null => {
|
||||
const boatId = bookingForm.value.interval?.resource;
|
||||
@@ -239,7 +108,6 @@ const onSubmit = async () => {
|
||||
auth.currentUser
|
||||
)
|
||||
) {
|
||||
// TODO: Make a proper validator
|
||||
return false;
|
||||
}
|
||||
const newReservation = <Reservation>{
|
||||
@@ -273,13 +141,87 @@ const onSubmit = async () => {
|
||||
spinner: false,
|
||||
});
|
||||
} catch (e) {
|
||||
status({
|
||||
color: 'negative',
|
||||
icon: 'error',
|
||||
spinner: false,
|
||||
message: 'Failed to book!' + e,
|
||||
});
|
||||
status({ color: 'negative', icon: 'error', spinner: false, message: 'Failed to book!' + e });
|
||||
}
|
||||
$router.go(-1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="q-pa-xs row q-gutter-xs">
|
||||
<q-card flat class="col-lg-4 col-md-6 col-sm-8 col-xs-12">
|
||||
<q-card-section>
|
||||
<div class="text-h5 q-mt-none q-mb-xs">
|
||||
{{ reservation ? 'Modify Booking' : 'New Booking' }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-8">for: {{ bookingName }}</div>
|
||||
</q-card-section>
|
||||
<q-list class="q-px-xs">
|
||||
<q-item class="q-pa-none" clickable @click="boatSelect = true">
|
||||
<q-card v-if="boat" class="col-12">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h7 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right text-caption">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="col-9">
|
||||
<q-list dense class="row">
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="Start" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2">
|
||||
{{ formatDate(bookingForm.interval?.start) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-ma-none col-12">
|
||||
<q-item-section avatar>
|
||||
<q-badge color="primary" label="End" />
|
||||
</q-item-section>
|
||||
<q-item-section class="text-body2" style="min-width: 150px">
|
||||
{{ formatDate(bookingForm.interval?.end) }}
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-separator vertical />
|
||||
<q-card-section class="col-3 flex flex-center bg-grey-4">
|
||||
{{ bookingDuration.hours }} hours
|
||||
<div v-if="bookingDuration.minutes">
|
||||
<q-separator />
|
||||
{{ bookingDuration.minutes }} mins
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div v-else class="col-12">
|
||||
<q-field filled>Tap to Select a Boat / Time</q-field>
|
||||
</div>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-select filled v-model="bookingForm.reason" :options="reason_options" label="Reason for sail" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item class="q-px-none">
|
||||
<q-item-section>
|
||||
<q-input v-model="bookingForm.comment" clearable autogrow filled label="Additional Comments (optional)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Delete" color="negative" size="lg" v-if="reservation?.$id" @click="onDelete" />
|
||||
<q-btn label="Reset" @click="onReset" size="lg" color="secondary" />
|
||||
<q-btn label="Submit" @click="onSubmit" size="lg" color="primary" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="boatSelect" full-width>
|
||||
<BoatScheduleTableComponent :model-value="bookingForm.interval" @update:model-value="updateInterval" />
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
8
app/components/BottomNavComponent.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<q-tabs class="mobile-only">
|
||||
<q-route-tab name="Boats" icon="sailing" to="/boat" />
|
||||
<q-route-tab name="Schedule" icon="calendar_month" to="/schedule" />
|
||||
</q-tabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
35
app/components/CertificationComponent.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const certifications = [
|
||||
{ title: 'J/27 Skipper', badgeText: 'J/27', description: 'Certified to be a skipper on a J/27 class boat.' },
|
||||
{ title: 'Capri 25 Skipper', badgeText: 'Capri25', description: 'Certified to be a skipper on a Capri 25 class boat.' },
|
||||
{ title: 'Night', badgeText: 'Night', description: 'Certified to operate boats at night' },
|
||||
{ title: 'Navigation', badgeText: 'Nav', description: 'Advanced Navigation' },
|
||||
{ title: 'Crew', badgeText: 'crew', description: 'Crew certification.' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Certification</div>
|
||||
<q-item
|
||||
v-for="cert in certifications"
|
||||
:key="cert.title"
|
||||
clickable
|
||||
v-ripple
|
||||
class="rounded-borders"
|
||||
:class="$q.dark.isActive ? 'bg-grey-9 text-white' : 'bg-grey-2'">
|
||||
<q-item-section avatar>
|
||||
<q-avatar rounded>
|
||||
<q-icon :name="`check`" />
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ cert.title }}</q-item-label>
|
||||
<q-item-label caption>
|
||||
<q-badge color="green-4" text-color="black">{{ cert.badgeText }}</q-badge>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<span>{{ cert.description }}</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
@@ -1,3 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
import { Dialog } from 'quasar';
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { APP_VERSION } from '~/utils/version';
|
||||
|
||||
defineProps(['drawer']);
|
||||
defineEmits(['drawer-toggle']);
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function showAbout() {
|
||||
Dialog.create({
|
||||
title: 'OYS Borrow a Boat',
|
||||
message: `Version ${APP_VERSION}<br>Manage a Borrow a Boat program for a Yacht Club.<br><br>© Oakville Yacht Squadron`,
|
||||
html: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await authStore.logout();
|
||||
await navigateTo('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-drawer
|
||||
:model-value="drawer"
|
||||
@@ -6,21 +32,12 @@
|
||||
:breakpoint="1024"
|
||||
@update:model-value="$emit('drawer-toggle')">
|
||||
<q-scroll-area class="fit">
|
||||
<q-list
|
||||
padding
|
||||
class="menu-list">
|
||||
<template
|
||||
v-for="link in enabledLinks"
|
||||
:key="link.name">
|
||||
<!-- TODO: Template this to be DRY -->
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="link.to">
|
||||
<q-list padding class="menu-list">
|
||||
<template v-for="link in enabledLinks" :key="link.name">
|
||||
<q-item clickable v-ripple :to="link.to">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="link.icon" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<span :class="link.color ? `text-${link.color}` : ''">
|
||||
{{ link.name }}
|
||||
@@ -28,18 +45,11 @@
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-list v-if="link.sublinks">
|
||||
<div
|
||||
v-for="sublink in link.sublinks"
|
||||
:key="sublink.name">
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="sublink.to"
|
||||
class="q-ml-md">
|
||||
<div v-for="sublink in link.sublinks" :key="sublink.name">
|
||||
<q-item clickable v-ripple :to="sublink.to" class="q-ml-md">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="sublink.icon" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<span :class="sublink.color ? `text-${sublink.color}` : ''">
|
||||
{{ sublink.name }}
|
||||
@@ -49,10 +59,11 @@
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
@click="logout()">
|
||||
<q-item clickable v-ripple @click="showAbout()">
|
||||
<q-item-section avatar><q-icon name="info" /></q-item-section>
|
||||
<q-item-section>About</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-ripple @click="logout()">
|
||||
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||
<q-item-section>Logout</q-item-section>
|
||||
</q-item>
|
||||
@@ -61,19 +72,6 @@
|
||||
</q-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineComponent } from 'vue';
|
||||
import { enabledLinks } from 'src/router/navlinks.js';
|
||||
import { logout } from 'boot/appwrite';
|
||||
|
||||
defineProps(['drawer']);
|
||||
defineEmits(['drawer-toggle']);
|
||||
|
||||
defineComponent({
|
||||
name: 'LeftDrawer',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.menu-list .q-item
|
||||
border-radius: 0 32px 32px 0
|
||||
@@ -1,50 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReferenceEntry } from '~/stores/reference';
|
||||
|
||||
defineProps({ entries: Array<ReferenceEntry> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="my-card"
|
||||
v-for="entry in entries"
|
||||
:key="entry.title"
|
||||
>
|
||||
<q-card flat bordered class="my-card" v-for="entry in entries" :key="entry.title">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ entry.title }}</div>
|
||||
<div class="text-subtitle2">{{ entry.subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<q-btn color="grey-7" round flat icon="more_vert">
|
||||
<q-menu cover auto-close>
|
||||
<q-list>
|
||||
<q-item clickable>
|
||||
<q-item-section>Remove Card</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section>Send Feedback</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section>Share</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable><q-item-section>Remove Card</q-item-section></q-item>
|
||||
<q-item clickable><q-item-section>Send Feedback</q-item-section></q-item>
|
||||
<q-item clickable><q-item-section>Share</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<q-card-actions>
|
||||
<q-btn flat :to="'reference/' + entry.id + '/view'">Read</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ReferenceEntry } from 'src/stores/reference';
|
||||
|
||||
defineProps({
|
||||
entries: Array<ReferenceEntry>,
|
||||
});
|
||||
</script>
|
||||
@@ -1,40 +1,116 @@
|
||||
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
|
||||
<!-- Abandoned: superseded by block-based booking. Retained for future reference. -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { TimestampOrNull, Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarResource,
|
||||
QCalendarMonth,
|
||||
today,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { date } from 'quasar';
|
||||
import { computed } from 'vue';
|
||||
import type { StatusTypes } from '~/utils/schedule.types';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
interface EventData {
|
||||
event: object;
|
||||
scope: { timestamp: object; columnindex: number; activeDate: boolean; droppable: boolean };
|
||||
}
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
interface ResourceIntervalScope {
|
||||
resource: Boat;
|
||||
intervals: [];
|
||||
timeStartPosX(start: TimestampOrNull): number;
|
||||
timeDurationWidth(duration: number): number;
|
||||
}
|
||||
|
||||
const statusLookup = {
|
||||
confirmed: ['#14539a', 'white'],
|
||||
pending: ['#f2c037', 'white'],
|
||||
tentative: ['white', 'grey'],
|
||||
};
|
||||
|
||||
const calendar = ref();
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const duration = ref(1);
|
||||
|
||||
const formattedMonth = computed(() => {
|
||||
const d = new Date(selectedDate.value);
|
||||
return monthFormatter()?.format(d);
|
||||
});
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
|
||||
function monthFormatter() {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA', { month: 'long', timeZone: 'UTC' });
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = reservationStore.getReservationsByDate(selectedDate.value, scope.resource.$id);
|
||||
return resourceEvents.value.map((event) => ({
|
||||
left: scope.timeStartPosX(parsed(event.start)),
|
||||
width: scope.timeDurationWidth(date.getDateDiff(event.end, event.start, 'minutes')),
|
||||
title: event.user,
|
||||
status: event.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function getStyle(event: { left: number; width: number; title: string; status: StatusTypes }) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
background: event.status ? statusLookup[event.status][0] : 'white',
|
||||
color: event.status ? statusLookup[event.status][1] : '#14539a',
|
||||
left: `${event.left}px`,
|
||||
width: `${event.width}px`,
|
||||
height: '32px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
function onClickDate(data: EventData) { return data; }
|
||||
function onClickTime(data: EventData) { emit('onClickTime', data); }
|
||||
function onUpdateDuration(value: EventData) { emit('onUpdateDuration', value); }
|
||||
const onClickInterval = () => {};
|
||||
const onClickHeadResources = () => {};
|
||||
const onClickResource = () => {};
|
||||
const onResourceExpanded = () => {};
|
||||
const onMoved = () => {};
|
||||
const onChange = () => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-justify">
|
||||
Use the calendar to pick a date. Tap a box in the grid for the boat and
|
||||
start time. Select the duration below.
|
||||
Use the calendar to pick a date. Tap a box in the grid for the boat and start time.
|
||||
</div>
|
||||
<div style="width: 100%; display: flex; justify-content: center">
|
||||
<div
|
||||
style="
|
||||
width: 50%;
|
||||
max-width: 350px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
">
|
||||
<span
|
||||
class="q-button"
|
||||
style="cursor: pointer; user-select: none"
|
||||
@click="onPrev">
|
||||
<
|
||||
</span>
|
||||
<div style="width: 50%; max-width: 350px; display: flex; justify-content: space-between">
|
||||
<span class="q-button" style="cursor: pointer; user-select: none" @click="onPrev"><</span>
|
||||
{{ formattedMonth }}
|
||||
<span
|
||||
class="q-button"
|
||||
style="cursor: pointer; user-select: none"
|
||||
@click="onNext">
|
||||
>
|
||||
</span>
|
||||
<span class="q-button" style="cursor: pointer; user-select: none" @click="onNext">></span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
">
|
||||
<div style="display: flex; justify-content: center; align-items: center; flex-wrap: nowrap">
|
||||
<div style="display: flex; width: 100%">
|
||||
<q-calendar-month
|
||||
ref="calendar"
|
||||
@@ -73,176 +149,20 @@
|
||||
@click-head-resources="onClickHeadResources"
|
||||
@click-interval="onClickInterval">
|
||||
<template #resource-intervals="{ scope }">
|
||||
<template
|
||||
v-for="(event, index) in getEvents(scope)"
|
||||
:key="index">
|
||||
<q-badge
|
||||
outline
|
||||
:label="event.title"
|
||||
:style="getStyle(event)" />
|
||||
<template v-for="(event, index) in getEvents(scope)" :key="index">
|
||||
<q-badge outline :label="event.title" :style="getStyle(event)" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #resource-label="{ scope: { resource } }">
|
||||
<div class="col-12 .col-md-auto">
|
||||
{{ resource.displayName }}
|
||||
<q-icon
|
||||
v-if="resource.defects"
|
||||
name="warning"
|
||||
color="warning" />
|
||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-resource>
|
||||
|
||||
<q-card-section>
|
||||
<q-select
|
||||
filled
|
||||
v-model="duration"
|
||||
:options="durations"
|
||||
dense
|
||||
@update:model-value="onUpdateDuration"
|
||||
label="Duration (hours)"
|
||||
stack-label>
|
||||
<q-select filled v-model="duration" :options="durations" dense @update:model-value="onUpdateDuration" label="Duration (hours)" stack-label>
|
||||
<template v-slot:append><q-icon name="timelapse" /></template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
QCalendarResource,
|
||||
TimestampOrNull,
|
||||
today,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
Timestamp,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { date } from 'quasar';
|
||||
import { computed } from 'vue';
|
||||
import type { StatusTypes } from 'src/stores/schedule.types';
|
||||
import { useIntervalStore } from 'src/stores/interval';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
interface EventData {
|
||||
event: object;
|
||||
scope: {
|
||||
timestamp: object;
|
||||
columnindex: number;
|
||||
activeDate: boolean;
|
||||
droppable: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
interface ResourceIntervalScope {
|
||||
resource: Boat;
|
||||
intervals: [];
|
||||
timeStartPosX(start: TimestampOrNull): number;
|
||||
timeDurationWidth(duration: number): number;
|
||||
}
|
||||
|
||||
const statusLookup = {
|
||||
confirmed: ['#14539a', 'white'],
|
||||
pending: ['#f2c037', 'white'],
|
||||
tentative: ['white', 'grey'],
|
||||
};
|
||||
|
||||
const calendar = ref();
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const duration = ref(1);
|
||||
|
||||
const formattedMonth = computed(() => {
|
||||
const date = new Date(selectedDate.value);
|
||||
|
||||
return monthFormatter()?.format(date);
|
||||
});
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
|
||||
function monthFormatter() {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA' || undefined, {
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = reservationStore.getReservationsByDate(
|
||||
selectedDate.value,
|
||||
scope.resource.$id
|
||||
);
|
||||
|
||||
return resourceEvents.value.map((event) => {
|
||||
return {
|
||||
left: scope.timeStartPosX(parsed(event.start)),
|
||||
width: scope.timeDurationWidth(
|
||||
date.getDateDiff(event.end, event.start, 'minutes')
|
||||
),
|
||||
title: event.user,
|
||||
status: event.status,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getStyle(event: {
|
||||
left: number;
|
||||
width: number;
|
||||
title: string;
|
||||
status: StatusTypes;
|
||||
}) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
background: event.status ? statusLookup[event.status][0] : 'white',
|
||||
color: event.status ? statusLookup[event.status][1] : '#14539a',
|
||||
left: `${event.left}px`,
|
||||
width: `${event.width}px`,
|
||||
height: '32px',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||
|
||||
function onPrev() {
|
||||
calendar.value.prev();
|
||||
}
|
||||
function onNext() {
|
||||
calendar.value.next();
|
||||
}
|
||||
|
||||
function onClickDate(data: EventData) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function onClickTime(data: EventData) {
|
||||
// TODO: Add a duration picker, here.
|
||||
emit('onClickTime', data);
|
||||
}
|
||||
function onUpdateDuration(value: EventData) {
|
||||
emit('onUpdateDuration', value);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onClickInterval = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onClickHeadResources = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onClickResource = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onResourceExpanded = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onMoved = () => {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const onChange = () => {};
|
||||
</script>
|
||||
@@ -2,5 +2,4 @@
|
||||
<div>My component</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
const boats = useBoatStore().boats;
|
||||
const boat = <Boat | undefined>undefined;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-select
|
||||
v-model="boat"
|
||||
:options="boats"
|
||||
option-value="id"
|
||||
option-label="name"
|
||||
label="Boat"
|
||||
>
|
||||
<q-select v-model="boat" :options="boats" option-value="id" option-label="name" label="Boat">
|
||||
<template v-slot:prepend>
|
||||
<q-item-section avatar>
|
||||
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||
<q-icon v-else name="sailing" />
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section avatar>
|
||||
@@ -24,17 +25,11 @@
|
||||
</q-item-section>
|
||||
<q-item-section avatar v-if="scope.opt.defects">
|
||||
<q-icon name="warning" color="warning" />
|
||||
<q-tooltip class="bg-amber text-black shadow-7"
|
||||
>This boat has notices. Select it to see details.
|
||||
<q-tooltip class="bg-amber text-black shadow-7">
|
||||
This boat has notices. Select it to see details.
|
||||
</q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
const boats = useBoatStore().boats;
|
||||
const boat = <Boat | undefined>undefined;
|
||||
</script>
|
||||
@@ -1,37 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
defineProps({ boats: Array<Boat> });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="boats">
|
||||
<div v-if="boats" class="row">
|
||||
<q-card
|
||||
v-for="boat in boats"
|
||||
:key="boat.id"
|
||||
class="q-ma-sm">
|
||||
:key="boat.$id"
|
||||
class="q-ma-sm col-xs-12 col-sm-6 col-xl-3">
|
||||
<q-card-section>
|
||||
<q-img
|
||||
:src="boat.imgSrc"
|
||||
:fit="'scale-down'">
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
|
||||
<!-- <q-card-actions align="evenly">
|
||||
<q-btn flat>Info</q-btn>
|
||||
<q-btn flat>Book</q-btn>
|
||||
<q-btn flat>Check-Out</q-btn>
|
||||
<q-btn flat>Check-In</q-btn>
|
||||
</q-card-actions> -->
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Boat } from 'src/stores/boat';
|
||||
|
||||
defineProps({
|
||||
boats: Array<Boat>,
|
||||
});
|
||||
</script>
|
||||
1
app/components/scheduling/BoatSelection.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
101
app/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||
import { copyIntervalTemplate, timeTuplesOverlapped } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const alert = ref(false);
|
||||
const overlapped = ref();
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||
const edit = ref(props.edit);
|
||||
const expanded = ref(props.edit);
|
||||
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||
|
||||
const revert = () => {
|
||||
template.value = copyIntervalTemplate(props.modelValue);
|
||||
edit.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
edit.value = !edit.value;
|
||||
};
|
||||
|
||||
const deleteTemplate = (event: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (tmpl?.$id) intervalTemplateStore.deleteIntervalTemplate(tmpl.$id);
|
||||
};
|
||||
|
||||
function onDragStart(e: DragEvent, tmpl: IntervalTemplate) {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('ID', tmpl.$id || '');
|
||||
}
|
||||
}
|
||||
|
||||
const saveTemplate = (evt: Event, tmpl: IntervalTemplate | undefined) => {
|
||||
if (!tmpl) return false;
|
||||
overlapped.value = timeTuplesOverlapped(tmpl.timeTuples);
|
||||
if (overlapped.value.length > 0) {
|
||||
alert.value = true;
|
||||
} else {
|
||||
edit.value = false;
|
||||
if (tmpl.$id && tmpl.$id !== 'unsaved') {
|
||||
intervalTemplateStore.updateIntervalTemplate(tmpl, tmpl.$id);
|
||||
} else {
|
||||
intervalTemplateStore.createIntervalTemplate(tmpl);
|
||||
emit('saved');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-expansion-item expand-icon-toggle draggable="true" @dragstart="onDragStart($event, template)" v-model="expanded">
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-input label="Template name" :borderless="!edit" dense v-model="template.name" v-if="edit" />
|
||||
<q-item-label v-if="!edit" class="cursor-pointer">{{ template.name }}</q-item-label>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-card flat>
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="q-pt-xs">
|
||||
<q-list dense>
|
||||
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
|
||||
<q-input class="q-mx-sm" dense v-model="item[0]" type="time" label="Start" :borderless="!edit" :readonly="!edit" />
|
||||
<q-input class="q-mx-sm" dense v-model="item[1]" type="time" label="End" :borderless="!edit" :readonly="!edit">
|
||||
<template v-slot:after>
|
||||
<q-btn v-if="edit" round dense flat icon="delete" @click="template.timeTuples.splice(index, 1)" />
|
||||
</template>
|
||||
</q-input>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-btn v-if="edit" dense color="primary" size="sm" label="Add interval" @click="template.timeTuples.push(['00:00', '00:00'])" />
|
||||
</q-card-section>
|
||||
<q-card-actions vertical>
|
||||
<q-btn v-if="!edit" color="primary" icon="edit" label="Edit" @click="toggleEdit" />
|
||||
<q-btn v-if="edit" color="primary" icon="save" label="Save" @click="saveTemplate($event, template)" />
|
||||
<q-btn v-if="edit" color="secondary" icon="cancel" label="Cancel" @click="revert" />
|
||||
<q-btn color="negative" icon="delete" label="Delete" v-if="template.$id !== ''" @click="deleteTemplate($event, template)" />
|
||||
</q-card-actions>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section><div class="text-h6">Overlapped blocks!</div></q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-chip square icon="schedule" v-for="item in overlapped" :key="item.start">
|
||||
{{ item.start }}-{{ item.end }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<div class="row justify-center">
|
||||
<div class="q-pa-md q-gutter-sm row">
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">
|
||||
Today
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">
|
||||
< Prev
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
|
||||
Next >
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">Today</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">< Prev</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">Next ></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
app/components/scheduling/ReservationCardComponent.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import { formatDate, isPast } from '~/utils/schedule';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const cancelDialog = ref(false);
|
||||
const boatStore = useBoatStore();
|
||||
const reservationStore = useReservationStore();
|
||||
|
||||
const reservation = defineModel<Reservation>({ required: true });
|
||||
|
||||
const cancelReservation = () => {
|
||||
cancelDialog.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card
|
||||
bordered
|
||||
:class="isPast(reservation.end) ? 'text-blue-grey-6' : ''"
|
||||
class="q-ma-md">
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-h6">
|
||||
{{ boatStore.getBoatById(reservation.resource)?.name }}
|
||||
</div>
|
||||
<div class="text-subtitle2">
|
||||
<p>
|
||||
Start: {{ formatDate(reservation.start) }}<br />
|
||||
End: {{ formatDate(reservation.end) }}<br />
|
||||
Type: {{ reservation.reason }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-actions v-if="!isPast(reservation.end)">
|
||||
<q-btn flat size="lg" :to="`/schedule/edit/${reservation.$id}`">Modify</q-btn>
|
||||
<q-btn flat size="lg" @click="cancelReservation()">Delete</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-dialog v-model="cancelDialog">
|
||||
<q-card>
|
||||
<q-card-section class="row items-center">
|
||||
<q-avatar icon="warning" color="negative" text-color="white" />
|
||||
<span class="q-ml-md">Warning!</span>
|
||||
<p class="q-pt-md">
|
||||
This will delete your reservation for
|
||||
{{ boatStore.getBoatById(reservation?.resource)?.name }} on
|
||||
{{ formatDate(reservation?.start) }}
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat size="lg" label="Cancel" color="primary" v-close-popup />
|
||||
<q-btn
|
||||
flat
|
||||
size="lg"
|
||||
label="Delete"
|
||||
color="negative"
|
||||
@click="reservationStore.deleteReservation(reservation)"
|
||||
v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
@@ -1,93 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<q-card>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||
<q-btn
|
||||
icon="close"
|
||||
flat
|
||||
round
|
||||
dense
|
||||
v-close-popup />
|
||||
</q-toolbar>
|
||||
<q-separator />
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe">
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #day-body="{ scope }">
|
||||
<div
|
||||
v-for="block in getAvailableIntervals(
|
||||
scope.timestamp,
|
||||
boats[scope.columnIndex]
|
||||
).value"
|
||||
:key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:disabled="beforeNow(new Date(block.end))"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="
|
||||
blockStyles(
|
||||
block,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
"
|
||||
:id="block.id"
|
||||
@click="selectBlock($event, scope, block)">
|
||||
{{ boats[scope.columnIndex].name }}
|
||||
<br />
|
||||
{{
|
||||
selectedBlock?.$id === block.$id ? 'Selected' : 'Available'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="reservation in getBoatReservations(scope)"
|
||||
:key="reservation.$id">
|
||||
<div
|
||||
class="reservation column"
|
||||
:style="
|
||||
reservationStyles(
|
||||
reservation,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
">
|
||||
{{ getUserName(reservation.user) || 'loading...' }}
|
||||
<br />
|
||||
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarDay,
|
||||
Timestamp,
|
||||
diffTimestamp,
|
||||
today,
|
||||
parseTimestamp,
|
||||
@@ -95,15 +9,14 @@ import {
|
||||
addToDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||
import { useIntervalStore } from 'src/stores/interval';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
|
||||
const intervalTemplateStore = useIntervalTemplateStore();
|
||||
const reservationStore = useReservationStore();
|
||||
@@ -111,23 +24,26 @@ const { boats } = storeToRefs(useBoatStore());
|
||||
const selectedBlock = defineModel<Interval | null>();
|
||||
const selectedDate = ref(today());
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const calendar = ref<QCalendarDay | null>(null);
|
||||
const calendar = ref<typeof QCalendarDay | null>(null);
|
||||
const now = ref(new Date());
|
||||
let intervalId: string | number | NodeJS.Timeout | undefined;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
await useBoatStore().fetchBoats();
|
||||
await intervalTemplateStore.fetchIntervalTemplates();
|
||||
intervalId = setInterval(function () {
|
||||
now.value = new Date();
|
||||
}, 60000);
|
||||
intervalId = setInterval(() => { now.value = new Date(); }, 60000);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearInterval(intervalId));
|
||||
|
||||
function handleSwipe({ ...event }) {
|
||||
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||
function handleSwipe({ direction }: { direction: string }) {
|
||||
if (direction === 'right') {
|
||||
calendar.value?.prev();
|
||||
} else {
|
||||
calendar.value?.next();
|
||||
}
|
||||
}
|
||||
|
||||
function reservationStyles(
|
||||
reservation: Reservation,
|
||||
timeStartPos: (t: string) => string,
|
||||
@@ -159,9 +75,7 @@ function blockStyles(
|
||||
}
|
||||
|
||||
function getBoatDisplayName(scope: DayBodyScope) {
|
||||
return boats && boats.value[scope.columnIndex]
|
||||
? boats.value[scope.columnIndex].displayName
|
||||
: '';
|
||||
return boats.value[scope.columnIndex]?.displayName ?? '';
|
||||
}
|
||||
|
||||
function beforeNow(time: Date) {
|
||||
@@ -174,19 +88,11 @@ function genericBlockStyle(
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
const s = {
|
||||
top: '',
|
||||
height: '',
|
||||
opacity: '',
|
||||
};
|
||||
const s = { top: '', height: '', opacity: '' };
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(start.time) + 'px';
|
||||
s.height =
|
||||
parseInt(
|
||||
timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)
|
||||
) -
|
||||
1 +
|
||||
'px';
|
||||
parseInt(timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)) - 1 + 'px';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -199,8 +105,7 @@ interface DayBodyScope {
|
||||
}
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
if (scope.timestamp.disabled || new Date(block.end) < new Date())
|
||||
return false;
|
||||
if (scope.timestamp.disabled || new Date(block.end) < new Date()) return false;
|
||||
selectedBlock.value = block;
|
||||
}
|
||||
|
||||
@@ -209,13 +114,14 @@ const boatReservations = computed((): Record<string, Reservation[]> => {
|
||||
.getReservationsByDate(selectedDate.value)
|
||||
.value.reduce((result, reservation) => {
|
||||
if (!result[reservation.resource]) result[reservation.resource] = [];
|
||||
result[reservation.resource].push(reservation);
|
||||
result[reservation.resource]!.push(reservation);
|
||||
return result;
|
||||
}, <Record<string, Reservation[]>>{});
|
||||
});
|
||||
|
||||
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||
const boat = boats.value[scope.columnIndex];
|
||||
return boat ? boatReservations.value[boat.$id] : [];
|
||||
return boat ? boatReservations.value[boat.$id] ?? [] : [];
|
||||
}
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
@@ -224,6 +130,65 @@ const disabledBefore = computed(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<q-card>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>Select a Boat and Time</q-toolbar-title>
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</q-toolbar>
|
||||
<q-separator />
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe">
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #day-body="{ scope }">
|
||||
<div
|
||||
v-for="block in getAvailableIntervals(scope.timestamp, boats[scope.columnIndex]).value"
|
||||
:key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:disabled="beforeNow(new Date(block.end))"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)"
|
||||
:id="block.$id"
|
||||
@click="selectBlock($event, scope, block)">
|
||||
{{ boats[scope.columnIndex]?.name }}<br />
|
||||
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="reservation in getBoatReservations(scope)" :key="reservation.$id">
|
||||
<div
|
||||
class="reservation column"
|
||||
:style="reservationStyles(reservation, scope.timeStartPos, scope.timeDurationHeight)">
|
||||
{{ getUserName(reservation.user) || 'loading...' }}<br />
|
||||
<q-chip class="gt-md">{{ reservation.reason }}</q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.boat-schedule-table-component
|
||||
display: flex
|
||||
@@ -1,71 +1,25 @@
|
||||
<template>
|
||||
<div class="title-bar" style="display: flex">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="date-button direction-button direction-button__left"
|
||||
@click="onPrev"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
<div class="dates-holder">
|
||||
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||
<button
|
||||
tabindex="0"
|
||||
style="width: 100%"
|
||||
:class="dayClass(day)"
|
||||
@click="selectedDate = day.date"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
<div style="width: 100%; font-size: 0.9em">
|
||||
{{ monthFormatter(day, true) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 1.2em; font-weight: 700">
|
||||
{{ dayFormatter(day, false) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 1em">
|
||||
{{ weekdayFormatter(day, true) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
tabindex="0"
|
||||
class="date-button direction-button direction-button__right"
|
||||
@click="onNext"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
Timestamp,
|
||||
addToDate,
|
||||
createDayList,
|
||||
createNativeLocaleFormatter,
|
||||
getEndOfWeek,
|
||||
getStartOfWeek,
|
||||
getWeekdaySkips,
|
||||
parseTimestamp,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
|
||||
const selectedDate = defineModel<string>();
|
||||
|
||||
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]),
|
||||
locale = ref('en-CA'),
|
||||
monthFormatter = monthFormatterFunc(),
|
||||
dayFormatter = dayFormatterFunc(),
|
||||
weekdayFormatter = weekdayFormatterFunc();
|
||||
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]);
|
||||
const locale = ref('en-CA');
|
||||
const monthFormatter = monthFormatterFunc();
|
||||
const dayFormatter = dayFormatterFunc();
|
||||
const weekdayFormatter = weekdayFormatterFunc();
|
||||
|
||||
const weekdaySkips = computed(() => {
|
||||
return getWeekdaySkips(weekdays);
|
||||
});
|
||||
const today2 = computed(() => parseTimestamp(today()));
|
||||
|
||||
const parsedStart = computed(() =>
|
||||
getStartOfWeek(
|
||||
@@ -83,90 +37,69 @@ const parsedEnd = computed(() =>
|
||||
)
|
||||
);
|
||||
|
||||
const today2 = computed(() => {
|
||||
return parseTimestamp(today());
|
||||
});
|
||||
|
||||
const days = computed(() => {
|
||||
if (parsedStart.value && parsedEnd.value) {
|
||||
return createDayList(
|
||||
parsedStart.value,
|
||||
parsedEnd.value,
|
||||
today2.value as Timestamp,
|
||||
weekdaySkips.value
|
||||
);
|
||||
return createDayList(parsedStart.value, parsedEnd.value, today2.value as Timestamp, weekdays);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const dayStyle = computed(() => {
|
||||
const width = 100 / weekdays.length + '%';
|
||||
return {
|
||||
width,
|
||||
};
|
||||
});
|
||||
const dayStyle = computed(() => ({ width: 100 / weekdays.length + '%' }));
|
||||
|
||||
function onPrev() {
|
||||
const ts = addToDate(parsedStart.value, { day: -7 });
|
||||
selectedDate.value = ts.date;
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: -7 }).date;
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
const ts = addToDate(parsedStart.value, { day: 7 });
|
||||
selectedDate.value = ts.date;
|
||||
selectedDate.value = addToDate(parsedStart.value, { day: 7 }).date;
|
||||
}
|
||||
|
||||
function dayClass(day: Timestamp) {
|
||||
return {
|
||||
'date-button': true,
|
||||
'selected-date-button': selectedDate.value === day.date,
|
||||
};
|
||||
return { 'date-button': true, 'selected-date-button': selectedDate.value === day.date };
|
||||
}
|
||||
|
||||
function monthFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
month: 'long',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
month: 'short',
|
||||
};
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', month: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function weekdayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'long',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'short',
|
||||
};
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'long' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', weekday: 'short' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
|
||||
function dayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
day: '2-digit',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
const longOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: '2-digit' };
|
||||
const shortOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC', day: 'numeric' };
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) => short ? shortOptions : longOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title-bar" style="display: flex">
|
||||
<button tabindex="0" class="date-button direction-button direction-button__left" @click="onPrev">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
<div class="dates-holder">
|
||||
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||
<button tabindex="0" style="width: 100%" :class="dayClass(day)" @click="selectedDate = day.date">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
<div style="width: 100%; font-size: 0.9em">{{ monthFormatter(day, true) }}</div>
|
||||
<div style="width: 100%; font-size: 1.2em; font-weight: 700">{{ dayFormatter(day, false) }}</div>
|
||||
<div style="width: 100%; font-size: 1em">{{ weekdayFormatter(day, true) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button tabindex="0" class="date-button direction-button direction-button__right" @click="onNext">
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="sass">
|
||||
.title-bar
|
||||
position: relative
|
||||
28
app/layouts/admin.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const leftDrawer = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-btn flat round dense icon="menu" @click="leftDrawer = !leftDrawer" />
|
||||
<q-toolbar-title>Admin</q-toolbar-title>
|
||||
</q-toolbar>
|
||||
<q-tabs>
|
||||
<q-route-tab icon="person" to="/admin/user" replace label="Users" />
|
||||
<q-route-tab icon="directions_boat" to="/admin/boat" replace label="Boats" />
|
||||
</q-tabs>
|
||||
</q-header>
|
||||
|
||||
<q-drawer v-model="leftDrawer" side="left" bordered content-class="bg-grey-2">
|
||||
<q-scroll-area class="fit q-pa-sm" />
|
||||
</q-drawer>
|
||||
|
||||
<q-page-container>
|
||||
<slot />
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
45
app/layouts/default.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import BottomNavComponent from '~/components/BottomNavComponent.vue';
|
||||
import LeftDrawer from '~/components/LeftDrawer.vue';
|
||||
import { APP_VERSION } from '~/utils/version';
|
||||
|
||||
const q = useQuasar();
|
||||
const route = useRoute();
|
||||
const leftDrawerOpen = ref(false);
|
||||
|
||||
function toggleLeftDrawer() {
|
||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||
}
|
||||
|
||||
q.addressbarColor?.set('#14539a');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh Lpr fFf">
|
||||
<q-header elevated>
|
||||
<q-toolbar>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="menu"
|
||||
aria-label="Menu"
|
||||
@click="toggleLeftDrawer" />
|
||||
<q-toolbar-title>{{ route?.meta?.title as string }}</q-toolbar-title>
|
||||
<q-space />
|
||||
<div>v{{ APP_VERSION }}</div>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
<LeftDrawer
|
||||
:drawer="leftDrawerOpen"
|
||||
@drawer-toggle="toggleLeftDrawer" />
|
||||
<q-page-container>
|
||||
<slot />
|
||||
</q-page-container>
|
||||
<q-footer>
|
||||
<BottomNavComponent />
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</template>
|
||||
27
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Public routes (set via definePageMeta({ public: true }) in each page)
|
||||
if (to.meta.public === true) {
|
||||
// Redirect already-authenticated users away from /login
|
||||
if (to.path === '/login' && authStore.currentUser) {
|
||||
return navigateTo('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other routes require auth
|
||||
if (!authStore.currentUser) {
|
||||
return navigateTo('/login');
|
||||
}
|
||||
|
||||
// Role-based access: pages set requiredRoles via definePageMeta
|
||||
const requiredRoles = to.meta.requiredRoles as string[] | undefined;
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
if (!authStore.hasRequiredRole(requiredRoles)) {
|
||||
return abortNavigation();
|
||||
}
|
||||
}
|
||||
});
|
||||
20
app/pages/[...slug].vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">404</div>
|
||||
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
label="Go Home"
|
||||
no-caps />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'admin', requiredRoles: ['admin'] });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
34
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
definePageMeta({ public: true, layout: false });
|
||||
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const userId = route.query.userId as string | undefined;
|
||||
const secret = route.query.secret as string | undefined;
|
||||
|
||||
if (!userId || !secret) {
|
||||
error.value = 'Invalid magic link — missing parameters.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.magicURLLogin(userId, secret);
|
||||
await navigateTo('/');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
error.value = 'Login failed. The link may have expired.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<div v-if="error" class="text-negative text-body1">{{ error }}</div>
|
||||
<q-spinner v-else color="primary" size="50px" />
|
||||
</q-page>
|
||||
</template>
|
||||
@@ -1,19 +1,18 @@
|
||||
<template>
|
||||
<toolbar-component pageTitle="Boats" />
|
||||
<q-page>
|
||||
<boat-preview-component :boats="boats" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BoatPreviewComponent from 'src/components/boat/BoatPreviewComponent.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import BoatPreviewComponent from '~/components/boat/BoatPreviewComponent.vue';
|
||||
import { useBoatStore } from '~/stores/boat';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
definePageMeta({ title: 'Boats' });
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
const { boats } = storeToRefs(boatStore);
|
||||
|
||||
onMounted(() => boatStore.fetchBoats());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<boat-preview-component :boats="boats" />
|
||||
</q-page>
|
||||
</template>
|
||||
11
app/pages/certification.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import CertificationComponent from '~/components/CertificationComponent.vue';
|
||||
|
||||
definePageMeta({ title: 'Certifications' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<CertificationComponent />
|
||||
</q-page>
|
||||
</template>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Checklists' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<toolbar-component pageTitle="Checklists" />
|
||||
<q-page padding>
|
||||
<q-card bordered separator style="max-width: 400px">
|
||||
<q-card-section clickable v-ripple>
|
||||
@@ -19,7 +22,3 @@
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
</script>
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
|
||||
definePageMeta({ title: 'OYS Borrow a Boat' });
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToolbarComponent />
|
||||
<q-page class="row justify-center">
|
||||
<q-img alt="OYS Logo" src="~assets/oysqn_logo.png" fit="scale-down" />
|
||||
<q-img alt="OYS Logo" src="/oysqn_logo.png" fit="scale-down" />
|
||||
<q-list class="full-width mobile-only">
|
||||
<q-item
|
||||
v-for="link in enabledLinks.filter((x) => x.front_links)"
|
||||
:key="link.name"
|
||||
>
|
||||
:key="link.name">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
color="primary"
|
||||
@@ -15,14 +21,8 @@
|
||||
:label="link.name"
|
||||
rounded
|
||||
class="full-width"
|
||||
:align="'left'"
|
||||
/>
|
||||
:align="'left'" />
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { enabledLinks } from 'src/router/navlinks.js';
|
||||
import ToolbarComponent from 'components/ToolbarComponent.vue';
|
||||
</script>
|
||||
@@ -1,3 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Dialog, Notify } from 'quasar';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { AppwriteException } from 'appwrite';
|
||||
|
||||
definePageMeta({ public: true, layout: false });
|
||||
|
||||
const email = ref('');
|
||||
const token = ref('');
|
||||
const userId = ref<string | undefined>();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const sendMagicLink = async () => {
|
||||
if (!email.value) {
|
||||
Dialog.create({ message: 'Please enter your e-mail address.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await authStore.createMagicURLSession(email.value);
|
||||
Dialog.create({ message: 'Check your e-mail for a magic login link.' });
|
||||
} catch {
|
||||
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||
}
|
||||
};
|
||||
|
||||
const doTokenLogin = async () => {
|
||||
if (!userId.value) {
|
||||
try {
|
||||
const sessionToken = await authStore.createTokenSession(email.value);
|
||||
userId.value = sessionToken.userId;
|
||||
Dialog.create({ message: 'Check your e-mail for your login code.' });
|
||||
} catch {
|
||||
Dialog.create({ message: 'An error occurred. Please ask for help in Discord.' });
|
||||
}
|
||||
} else {
|
||||
const notification = Notify.create({
|
||||
type: 'primary',
|
||||
position: 'top',
|
||||
spinner: true,
|
||||
message: 'Logging you in...',
|
||||
timeout: 8000,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
await authStore.tokenLogin(userId.value, token.value);
|
||||
notification({ type: 'positive', message: 'Logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||
await navigateTo('/');
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppwriteException) {
|
||||
if (error.type === 'user_session_already_exists') {
|
||||
notification({ type: 'positive', message: 'Already logged in!', timeout: 2000, spinner: false, icon: 'check_circle' });
|
||||
await navigateTo('/');
|
||||
return;
|
||||
}
|
||||
Dialog.create({ title: 'Login Error!', message: error.message, persistent: true });
|
||||
}
|
||||
notification({ type: 'negative', message: 'Login failed.', timeout: 2000 });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
@@ -5,9 +68,7 @@
|
||||
<q-card
|
||||
v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img
|
||||
fit="scale-down"
|
||||
src="~assets/oysqn_logo.png" />
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
@@ -21,37 +82,33 @@
|
||||
label="E-Mail"
|
||||
type="email"
|
||||
color="darkblue"
|
||||
filled></q-input>
|
||||
filled />
|
||||
<q-input
|
||||
v-if="userId"
|
||||
v-model="token"
|
||||
label="6-digit code"
|
||||
type="number"
|
||||
color="darkblue"
|
||||
filled></q-input>
|
||||
filled />
|
||||
</q-card-section>
|
||||
</q-form>
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<q-btn
|
||||
v-if="!userId"
|
||||
type="button"
|
||||
@click="doTokenLogin"
|
||||
color="primary"
|
||||
label="Login with E-mail"
|
||||
@click="sendMagicLink"
|
||||
color="secondary"
|
||||
label="Send Magic Link"
|
||||
style="width: 300px" />
|
||||
</div>
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<GoogleOauthComponent />
|
||||
</div>
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<DiscordOauthComponent />
|
||||
</div>
|
||||
<div class="row justify-center">
|
||||
<q-btn
|
||||
flat
|
||||
color="secondary"
|
||||
to="/pwreset"
|
||||
label="Forgot Password?" />
|
||||
type="button"
|
||||
@click="doTokenLogin"
|
||||
color="primary"
|
||||
:label="userId ? 'Login' : 'Send Code'"
|
||||
style="width: 300px" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
@@ -62,92 +119,9 @@
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('/src/assets/oys_lighthouse.jpg');
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
/* background-image: linear-gradient(
|
||||
135deg,
|
||||
#ed232a 0%,
|
||||
#ffffff 75%,
|
||||
#14539a 100%
|
||||
); */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import GoogleOauthComponent from 'src/components/GoogleOauthComponent.vue';
|
||||
import DiscordOauthComponent from 'src/components/DiscordOauthComponent.vue';
|
||||
import { Dialog, Notify } from 'quasar';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AppwriteException } from 'appwrite';
|
||||
import { APP_VERSION } from 'src/version.js';
|
||||
|
||||
const email = ref('');
|
||||
const token = ref('');
|
||||
const userId = ref();
|
||||
const router = useRouter();
|
||||
|
||||
console.log('version:' + APP_VERSION);
|
||||
|
||||
const doTokenLogin = async () => {
|
||||
const authStore = useAuthStore();
|
||||
if (!userId.value) {
|
||||
try {
|
||||
const sessionToken = await authStore.createTokenSession(email.value);
|
||||
userId.value = sessionToken.userId;
|
||||
Dialog.create({ message: 'Check your e-mail for your login code.' });
|
||||
} catch (e) {
|
||||
Dialog.create({
|
||||
message: 'An error occurred. Please ask for help in Discord',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const notification = Notify.create({
|
||||
type: 'primary',
|
||||
position: 'top',
|
||||
spinner: true,
|
||||
message: 'Logging you in...',
|
||||
timeout: 8000,
|
||||
group: false,
|
||||
});
|
||||
try {
|
||||
await authStore.tokenLogin(userId.value, token.value);
|
||||
notification({
|
||||
type: 'positive',
|
||||
message: 'Logged in!',
|
||||
timeout: 2000,
|
||||
spinner: false,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
router.replace({ name: 'index' });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AppwriteException) {
|
||||
if (error.type === 'user_session_already_exists') {
|
||||
useRouter().replace({ name: 'index' });
|
||||
notification({
|
||||
type: 'positive',
|
||||
message: 'Already Logged in!',
|
||||
timeout: 2000,
|
||||
spinner: false,
|
||||
icon: 'check_circle',
|
||||
});
|
||||
return;
|
||||
}
|
||||
Dialog.create({
|
||||
title: 'Login Error!',
|
||||
message: error.message,
|
||||
persistent: true,
|
||||
});
|
||||
}
|
||||
notification({
|
||||
type: 'negative',
|
||||
message: 'Login failed.',
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
43
app/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page padding>
|
||||
<h1>Privacy Policy for Undock.ca</h1>
|
||||
|
||||
<p>
|
||||
At Undock, accessible from https://undock.ca, one of our main priorities is the
|
||||
privacy of our visitors. This Privacy Policy document contains types of information
|
||||
that is collected and recorded by Undock and how we use it.
|
||||
</p>
|
||||
|
||||
<h2>General Data Protection Regulation (GDPR)</h2>
|
||||
<p>We are a Data Controller of your information.</p>
|
||||
|
||||
<h2>Log Files</h2>
|
||||
<p>
|
||||
Undock follows a standard procedure of using log files. These files log visitors
|
||||
when they visit websites. The information collected by log files include internet
|
||||
protocol (IP) addresses, browser type, Internet Service Provider (ISP), date and
|
||||
time stamp, referring/exit pages, and possibly the number of clicks.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and Web Beacons</h2>
|
||||
<p>
|
||||
Like any other website, Undock uses "cookies". These cookies are used to store
|
||||
information including visitors' preferences, and the pages on the website that
|
||||
the visitor accessed or visited.
|
||||
</p>
|
||||
|
||||
<h2>Consent</h2>
|
||||
<p>
|
||||
By using our website, you hereby consent to our Privacy Policy and agree to its
|
||||
<a href="/terms-of-service">terms</a>.
|
||||
</p>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
@@ -1,8 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { ref } from 'vue';
|
||||
|
||||
definePageMeta({ title: 'Member Profile' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const newName = ref<string | undefined>();
|
||||
|
||||
const editName = async () => {
|
||||
if (newName.value) {
|
||||
try {
|
||||
await authStore.updateName(newName.value);
|
||||
newName.value = undefined;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
newName.value = authStore.currentUser?.name || '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<toolbar-component pageTitle="Member Profile" />
|
||||
<q-page
|
||||
padding
|
||||
class="row">
|
||||
<q-page padding class="row">
|
||||
<q-list class="col-sm-4 col-12">
|
||||
<q-separator />
|
||||
<q-item>
|
||||
@@ -16,9 +36,7 @@
|
||||
v-model="newName"
|
||||
@keydown.enter.prevent="editName"
|
||||
v-if="newName !== undefined" />
|
||||
<div v-else>
|
||||
{{ authStore.currentUser?.name }}
|
||||
</div>
|
||||
<div v-else>{{ authStore.currentUser?.name }}</div>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-btn
|
||||
@@ -47,52 +65,12 @@
|
||||
<q-item-section>
|
||||
<q-item-label overline>Certifications</q-item-label>
|
||||
<div>
|
||||
<q-chip
|
||||
square
|
||||
icon="verified"
|
||||
color="green"
|
||||
text-color="white">
|
||||
J/27
|
||||
</q-chip>
|
||||
<q-chip
|
||||
square
|
||||
icon="verified"
|
||||
color="blue"
|
||||
text-color="white">
|
||||
Capri25
|
||||
</q-chip>
|
||||
<q-chip
|
||||
square
|
||||
icon="verified"
|
||||
color="grey-9"
|
||||
text-color="white">
|
||||
Night
|
||||
</q-chip>
|
||||
<q-chip square icon="verified" color="green" text-color="white">J/27</q-chip>
|
||||
<q-chip square icon="verified" color="blue" text-color="white">Capri25</q-chip>
|
||||
<q-chip square icon="verified" color="grey-9" text-color="white">Night</q-chip>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const newName = ref();
|
||||
|
||||
const editName = async () => {
|
||||
if (newName.value) {
|
||||
try {
|
||||
await authStore.updateName(newName.value);
|
||||
newName.value = undefined;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
newName.value = authStore.currentUser?.name || '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
38
app/pages/pwreset.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// NOTE: Password reset removed — auth is magic link + OTP only.
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="text-h6">Password Reset</div>
|
||||
<div class="text-body2 q-mt-md">
|
||||
This application uses magic link and email code login — no password is required.
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
7
app/pages/reference.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Reference' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
13
app/pages/reference/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import ReferencePreviewComponent from '~/components/ReferencePreviewComponent.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useReferenceStore } from '~/stores/reference';
|
||||
|
||||
const items = ref(useReferenceStore().allItems);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<ReferencePreviewComponent :entries="items" />
|
||||
</q-page>
|
||||
</template>
|
||||
@@ -4,8 +4,7 @@
|
||||
<q-video
|
||||
title="Engine Starting"
|
||||
:ratio="16 / 9"
|
||||
src="https://www.youtube.com/embed/GMHMLDlkKcE"
|
||||
></q-video>
|
||||
src="https://www.youtube.com/embed/GMHMLDlkKcE" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ title: 'Schedule' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
28
app/pages/schedule/book.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const newReservation = ref<Reservation>();
|
||||
|
||||
if (typeof route.query.interval === 'string') {
|
||||
useIntervalStore()
|
||||
.fetchInterval(route.query.interval)
|
||||
.then(
|
||||
(interval: Interval) =>
|
||||
(newReservation.value = <Reservation>{
|
||||
resource: interval.resource,
|
||||
start: interval.start,
|
||||
end: interval.end,
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<BoatReservationComponent v-model="newReservation" />
|
||||
</q-page>
|
||||
</template>
|
||||
20
app/pages/schedule/edit/[id].vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import BoatReservationComponent from '~/components/BoatReservationComponent.vue';
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const route = useRoute();
|
||||
const reservation = ref<Reservation>();
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string;
|
||||
reservation.value = await useReservationStore().getReservationById(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<BoatReservationComponent v-model="reservation" />
|
||||
</q-page>
|
||||
</template>
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useNavLinks } from '~/utils/navlinks';
|
||||
|
||||
const { enabledLinks } = useNavLinks();
|
||||
const navlinks = enabledLinks.find((link) => link.name === 'Schedule')?.sublinks;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-item
|
||||
v-for="link in navlinks"
|
||||
:key="link.name">
|
||||
<q-item v-for="link in navlinks" :key="link.name">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
:color="link.color ? link.color : 'primary'"
|
||||
@@ -15,11 +20,3 @@
|
||||
</q-item>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { enabledLinks } from 'src/router/navlinks';
|
||||
|
||||
const navlinks = enabledLinks.find(
|
||||
(link) => link.name === 'Schedule'
|
||||
)?.sublinks;
|
||||
</script>
|
||||
55
app/pages/schedule/list.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import ReservationCardComponent from '~/components/scheduling/ReservationCardComponent.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
onMounted(() => reservationStore.fetchUserReservations());
|
||||
|
||||
const tab = ref('upcoming');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<q-tabs v-model="tab" inline-label class="text-primary">
|
||||
<q-tab name="upcoming" icon="schedule" label="Upcoming" />
|
||||
<q-tab name="past" icon="history" label="Past" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
<q-tab-panel name="upcoming" class="q-pa-none">
|
||||
<q-card clas="q-ma-md" v-if="!reservationStore.futureUserReservations.length">
|
||||
<q-card-section>
|
||||
<div class="text-h6">You don't have any upcoming bookings!</div>
|
||||
<div class="text-h8">Why don't you go make one?</div>
|
||||
</q-card-section>
|
||||
<q-card-actions>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="event"
|
||||
:size="`1.25em`"
|
||||
label="Book Now"
|
||||
rounded
|
||||
class="full-width"
|
||||
:align="'left'"
|
||||
to="/schedule/book" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="reservation in reservationStore.futureUserReservations"
|
||||
:key="reservation.$id">
|
||||
<ReservationCardComponent :modelValue="reservation" />
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="past" class="q-pa-none">
|
||||
<div
|
||||
v-for="reservation in reservationStore.pastUserReservations"
|
||||
:key="reservation.$id">
|
||||
<ReservationCardComponent :modelValue="reservation" />
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-page>
|
||||
</template>
|
||||
@@ -1,180 +1,21 @@
|
||||
<template>
|
||||
<div class="fit row wrap justify-start items-start content-start">
|
||||
<div class="q-pa-md">
|
||||
<div
|
||||
class="scheduler"
|
||||
style="max-width: 1200px">
|
||||
<NavigationBar
|
||||
@next="onNext"
|
||||
@today="onToday"
|
||||
@prev="onPrev" />
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boats"
|
||||
resource-key="$id"
|
||||
resource-label="name"
|
||||
view="week"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
animated
|
||||
bordered
|
||||
:drag-enter-func="onDragEnter"
|
||||
:drag-over-func="onDragOver"
|
||||
:drag-leave-func="onDragLeave"
|
||||
:drop-func="onDrop"
|
||||
day-min-height="50px"
|
||||
cell-width="150px">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-if="
|
||||
filteredIntervals(scope.timestamp, scope.resource).value.length
|
||||
"
|
||||
style="
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
">
|
||||
<template
|
||||
v-for="block in sortedIntervals(scope.timestamp, scope.resource)
|
||||
.value"
|
||||
:key="block.id">
|
||||
<q-chip class="cursor-pointer">
|
||||
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||
<!-- <q-popup-edit
|
||||
:model-value="block"
|
||||
v-slot="scope"
|
||||
buttons
|
||||
@save="saveInterval"
|
||||
>
|
||||
TODO: Why isn't this saving?
|
||||
<q-input
|
||||
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
|
||||
dense
|
||||
autofocus
|
||||
type="time"
|
||||
label="start"
|
||||
@keyup.enter="scope.set"
|
||||
@update:model-value="
|
||||
(t) => {
|
||||
block.start = new Date(
|
||||
scope.value.start.split('T')[0] + 'T' + t
|
||||
).toISOString();
|
||||
}
|
||||
"
|
||||
/>
|
||||
TODO: Clean this up
|
||||
<q-input
|
||||
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
|
||||
dense
|
||||
type="time"
|
||||
label="end"
|
||||
@keyup.enter="scope.set"
|
||||
@update:model-value="
|
||||
(t) =>
|
||||
(block.end = new Date(
|
||||
scope.value.end.split('T')[0] + 'T' + t
|
||||
).toISOString())
|
||||
"
|
||||
/>
|
||||
</q-popup-edit>-->
|
||||
</q-chip>
|
||||
<q-btn
|
||||
size="xs"
|
||||
icon="delete"
|
||||
round
|
||||
@click="deleteBlock(block)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="q-pa-md"
|
||||
style="width: 400px">
|
||||
<q-list
|
||||
padding
|
||||
bordered
|
||||
class="rounded-borders">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Availability Templates</q-item-label>
|
||||
<q-item-label caption>
|
||||
Drag and drop a template to a boat / date to create booking
|
||||
availability
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
label="Add Template"
|
||||
color="primary"
|
||||
@click="createTemplate" />
|
||||
</q-card-actions>
|
||||
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||
<IntervalTemplateComponent
|
||||
:model-value="newTemplate"
|
||||
:edit="true"
|
||||
@cancel="resetNewTemplate"
|
||||
@saved="resetNewTemplate" />
|
||||
</q-item>
|
||||
<q-separator spaced />
|
||||
<IntervalTemplateComponent
|
||||
v-for="template in intervalTemplates"
|
||||
:key="template.$id"
|
||||
:model-value="template" />
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Warning!</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
Conflicting times! Please delete overlapped items!
|
||||
<q-chip
|
||||
v-for="item in overlapped"
|
||||
:key="item.index">
|
||||
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
label="OK"
|
||||
color="primary"
|
||||
v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import {
|
||||
QCalendarScheduler,
|
||||
Timestamp,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { useIntervalStore } from 'src/stores/interval';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type {
|
||||
Interval,
|
||||
IntervalTemplate,
|
||||
TimeTuple,
|
||||
} from 'src/stores/schedule.types';
|
||||
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||
import { date } from 'quasar';
|
||||
import IntervalTemplateComponent from 'src/components/scheduling/IntervalTemplateComponent.vue';
|
||||
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||
import IntervalTemplateComponent from '~/components/scheduling/IntervalTemplateComponent.vue';
|
||||
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { buildInterval, intervalsOverlapped } from 'src/utils/schedule';
|
||||
import { useIntervalTemplateStore } from 'src/stores/intervalTemplate';
|
||||
import { buildInterval, intervalsOverlapped } from '~/utils/schedule';
|
||||
import { useIntervalTemplateStore } from '~/stores/intervalTemplate';
|
||||
|
||||
definePageMeta({ requiredRoles: ['Schedule Admins'] });
|
||||
|
||||
const selectedDate = ref(today());
|
||||
const { fetchBoats } = useBoatStore();
|
||||
@@ -191,25 +32,18 @@ const newTemplate = ref<IntervalTemplate>({
|
||||
timeTuples: [['09:00', '12:00']],
|
||||
});
|
||||
|
||||
/* TODOS:
|
||||
* Need more validation:
|
||||
- Interval start < end
|
||||
- Intervals don't overlap
|
||||
* Need to handle case of overnight blocks.
|
||||
*/
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchBoats();
|
||||
await intervalTemplateStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
const filteredIntervals = (date: Timestamp, boat: Boat) => {
|
||||
return intervalStore.getIntervals(date, boat);
|
||||
const filteredIntervals = (ts: Timestamp, boat: Boat) => {
|
||||
return intervalStore.getIntervals(ts, boat);
|
||||
};
|
||||
|
||||
const sortedIntervals = (date: Timestamp, boat: Boat) => {
|
||||
const sortedIntervals = (ts: Timestamp, boat: Boat) => {
|
||||
return computed(() =>
|
||||
filteredIntervals(date, boat).value.sort(
|
||||
filteredIntervals(ts, boat).value.sort(
|
||||
(a, b) => Date.parse(a.start) - Date.parse(b.start)
|
||||
)
|
||||
);
|
||||
@@ -225,26 +59,26 @@ function resetNewTemplate() {
|
||||
function createTemplate() {
|
||||
newTemplate.value.$id = 'unsaved';
|
||||
}
|
||||
function createIntervals(boat: Boat, templateId: string, date: string) {
|
||||
const intervals = intervalsFromTemplate(boat, templateId, date);
|
||||
function createIntervals(boat: Boat, templateId: string, dateStr: string) {
|
||||
const intervals = intervalsFromTemplate(boat, templateId, dateStr);
|
||||
intervals.forEach((interval) => intervalStore.createInterval(interval));
|
||||
}
|
||||
|
||||
function getIntervals(date: Timestamp, boat: Boat) {
|
||||
return intervalStore.getIntervals(date, boat);
|
||||
function getIntervals(ts: Timestamp, boat: Boat) {
|
||||
return intervalStore.getIntervals(ts, boat);
|
||||
}
|
||||
|
||||
function intervalsFromTemplate(
|
||||
boat: Boat,
|
||||
templateId: string,
|
||||
date: string
|
||||
dateStr: string
|
||||
): Interval[] {
|
||||
const template = intervalTemplateStore
|
||||
.getIntervalTemplates()
|
||||
.value.find((t) => t.$id === templateId);
|
||||
return template
|
||||
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||
buildInterval(boat, timeTuple, date)
|
||||
buildInterval(boat, timeTuple, dateStr)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
@@ -278,7 +112,6 @@ function onDragLeave(e: DragEvent, type: string) {
|
||||
}
|
||||
|
||||
function onDrop(
|
||||
//TODO: Move all overlap checking to the store. This is too messy right now.
|
||||
e: DragEvent,
|
||||
type: string,
|
||||
scope: { resource: Boat; timestamp: Timestamp }
|
||||
@@ -288,7 +121,7 @@ function onDrop(
|
||||
|
||||
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
|
||||
const templateId = e.dataTransfer.getData('ID');
|
||||
const date = scope.timestamp.date;
|
||||
const dateStr = scope.timestamp.date;
|
||||
const resource = scope.resource;
|
||||
const existingIntervals = getIntervals(scope.timestamp, resource);
|
||||
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||
@@ -296,13 +129,13 @@ function onDrop(
|
||||
.map((boat) =>
|
||||
intervalsOverlapped(
|
||||
existingIntervals.value.concat(
|
||||
intervalsFromTemplate(boat, templateId, date)
|
||||
intervalsFromTemplate(boat, templateId, dateStr)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat(1);
|
||||
if (overlapped.value.length === 0) {
|
||||
boatsToApply.map((b) => createIntervals(b, templateId, date));
|
||||
boatsToApply.map((b) => createIntervals(b, templateId, dateStr));
|
||||
} else {
|
||||
alert.value = true;
|
||||
}
|
||||
@@ -312,13 +145,94 @@ function onDrop(
|
||||
return false;
|
||||
}
|
||||
|
||||
function onToday() {
|
||||
calendar.value.moveToToday();
|
||||
}
|
||||
function onPrev() {
|
||||
calendar.value.prev();
|
||||
}
|
||||
function onNext() {
|
||||
calendar.value.next();
|
||||
}
|
||||
function onToday() { calendar.value.moveToToday(); }
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fit row">
|
||||
<div class="q-pa-md">
|
||||
<div class="scheduler col-12">
|
||||
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boats"
|
||||
resource-key="$id"
|
||||
resource-label="name"
|
||||
view="week"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
animated
|
||||
bordered
|
||||
:drag-enter-func="onDragEnter"
|
||||
:drag-over-func="onDragOver"
|
||||
:drag-leave-func="onDragLeave"
|
||||
:drop-func="onDrop"
|
||||
day-min-height="50px"
|
||||
cell-width="150px">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-if="filteredIntervals(scope.timestamp, scope.resource).value.length"
|
||||
style="display: flex; flex-wrap: wrap; justify-content: space-evenly; align-items: center; font-size: 12px;">
|
||||
<template
|
||||
v-for="block in sortedIntervals(scope.timestamp, scope.resource).value"
|
||||
:key="block.$id">
|
||||
<q-chip class="cursor-pointer">
|
||||
{{ date.formatDate(block.start, 'HH:mm') }} -
|
||||
{{ date.formatDate(block.end, 'HH:mm') }}
|
||||
</q-chip>
|
||||
<q-btn size="xs" icon="delete" round @click="deleteBlock(block)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</q-calendar-scheduler>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-pa-md" style="width: 400px">
|
||||
<q-list padding bordered class="rounded-borders">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Availability Templates</q-item-label>
|
||||
<q-item-label caption>
|
||||
Drag and drop a template to a boat / date to create booking availability
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-card-actions align="right">
|
||||
<q-btn label="Add Template" color="primary" @click="createTemplate" />
|
||||
</q-card-actions>
|
||||
<q-item v-if="newTemplate.$id === 'unsaved'">
|
||||
<IntervalTemplateComponent
|
||||
:model-value="newTemplate"
|
||||
:edit="true"
|
||||
@cancel="resetNewTemplate"
|
||||
@saved="resetNewTemplate" />
|
||||
</q-item>
|
||||
<q-separator spaced />
|
||||
<IntervalTemplateComponent
|
||||
v-for="template in intervalTemplates"
|
||||
:key="template.$id"
|
||||
:model-value="template" />
|
||||
</q-list>
|
||||
</div>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Warning!</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
Conflicting times! Please delete overlapped items!
|
||||
<q-chip v-for="item in overlapped" :key="item.index">
|
||||
{{ boats.find((b) => b.$id === item.boatId)?.name }}:
|
||||
{{ date.formatDate(item.start, 'hh:mm') }} -
|
||||
{{ date.formatDate(item.end, 'hh:mm') }}
|
||||
</q-chip>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { useReservationStore } from '~/stores/reservation';
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { getDate, QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import NavigationBar from '~/components/scheduling/NavigationBar.vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { formatTime } from '~/utils/schedule';
|
||||
import { useIntervalStore } from '~/stores/interval';
|
||||
import type { Interval, Reservation } from '~/utils/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
const boatStore = useBoatStore();
|
||||
const calendar = ref();
|
||||
const $q = useQuasar();
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const currentUser = useAuthStore().currentUser;
|
||||
|
||||
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||
return getAvailableIntervals(timestamp, boat)
|
||||
.value.concat(boatReservationEvents(timestamp, boat))
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||
};
|
||||
|
||||
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||
if (interval.user) {
|
||||
if (interval.user === currentUser?.$id) {
|
||||
navigateTo(`/schedule/edit/${interval.$id}`);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
navigateTo({ path: '/schedule/book', query: { interval: interval.$id } });
|
||||
}
|
||||
};
|
||||
|
||||
function handleSwipe({ ...event }: { direction: string }) {
|
||||
if (event.direction === 'right') {
|
||||
calendar.value?.prev();
|
||||
} else {
|
||||
calendar.value?.next();
|
||||
}
|
||||
}
|
||||
|
||||
const boatReservationEvents = (
|
||||
timestamp: Timestamp,
|
||||
resource: Boat | undefined
|
||||
): Reservation[] => {
|
||||
if (!resource) return [] as Reservation[];
|
||||
return reservationStore.getReservationsByDate(
|
||||
getDate(timestamp),
|
||||
(resource as Boat).$id
|
||||
).value;
|
||||
};
|
||||
|
||||
function onToday() { calendar.value.moveToToday(); }
|
||||
function onPrev() { calendar.value.prev(); }
|
||||
function onNext() { calendar.value.next(); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page>
|
||||
<div class="col">
|
||||
<navigation-bar
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext" />
|
||||
<navigation-bar @today="onToday" @prev="onPrev" @next="onNext" />
|
||||
</div>
|
||||
<div class="col q-ma-sm">
|
||||
<q-calendar-scheduler
|
||||
@@ -22,10 +83,7 @@
|
||||
style="--calendar-resources-width: 40px">
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-for="interval in getSortedIntervals(
|
||||
scope.timestamp,
|
||||
scope.resource
|
||||
)"
|
||||
v-for="interval in getSortedIntervals(scope.timestamp, scope.resource)"
|
||||
:key="interval.$id"
|
||||
class="q-pb-xs row"
|
||||
@click="createReservationFromInterval(interval)">
|
||||
@@ -36,7 +94,7 @@
|
||||
:transparent="interval.user != undefined"
|
||||
:color="interval.user ? 'secondary' : 'primary'"
|
||||
:outline="!interval.user"
|
||||
:id="interval.id">
|
||||
:id="interval.$id">
|
||||
{{
|
||||
interval.user
|
||||
? useAuthStore().getUserNameById(interval.user)
|
||||
@@ -54,107 +112,6 @@
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
import { getDate } from '@quasar/quasar-ui-qcalendar';
|
||||
import { QCalendarScheduler } from '@quasar/quasar-ui-qcalendar';
|
||||
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import NavigationBar from 'src/components/scheduling/NavigationBar.vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { formatTime } from 'src/utils/schedule';
|
||||
import { useIntervalStore } from 'src/stores/interval';
|
||||
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
const calendar = ref();
|
||||
const $q = useQuasar();
|
||||
const $router = useRouter();
|
||||
const { getAvailableIntervals } = useIntervalStore();
|
||||
const { selectedDate } = storeToRefs(useIntervalStore());
|
||||
const currentUser = useAuthStore().currentUser;
|
||||
|
||||
// interface DayScope {
|
||||
// timestamp: Timestamp;
|
||||
// columnIndex: number;
|
||||
// resource: object;
|
||||
// resourceIndex: number;
|
||||
// indentLevel: number;
|
||||
// activeDate: boolean;
|
||||
// droppable: boolean;
|
||||
// }
|
||||
|
||||
const getSortedIntervals = (timestamp: Timestamp, boat?: Boat): Interval[] => {
|
||||
return getAvailableIntervals(timestamp, boat)
|
||||
.value.concat(boatReservationEvents(timestamp, boat))
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
||||
};
|
||||
// Method declarations
|
||||
|
||||
// function slotStyle(
|
||||
// event: Reservation,
|
||||
// timeStartPos: (time: TimestampOrNull) => string,
|
||||
// timeDurationHeight: (minutes: number) => string
|
||||
// ) {
|
||||
// const s = {
|
||||
// top: '',
|
||||
// height: '',
|
||||
// 'align-items': 'flex-start',
|
||||
// };
|
||||
// if (timeStartPos && timeDurationHeight) {
|
||||
// s.top = timeStartPos(parsed(event.start)) + 'px';
|
||||
// s.height =
|
||||
// timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
|
||||
// 'px';
|
||||
// }
|
||||
// return s;
|
||||
// }
|
||||
|
||||
const createReservationFromInterval = (interval: Interval | Reservation) => {
|
||||
if (interval.user) {
|
||||
if (interval.user === currentUser?.$id) {
|
||||
$router.push({ name: 'edit-reservation', params: { id: interval.$id } });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$router.push({
|
||||
name: 'reserve-boat',
|
||||
query: { interval: interval.$id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handleSwipe({ ...event }) {
|
||||
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||
}
|
||||
const boatReservationEvents = (
|
||||
timestamp: Timestamp,
|
||||
resource: Boat | undefined
|
||||
): Reservation[] => {
|
||||
if (!resource) return [] as Reservation[];
|
||||
return reservationStore.getReservationsByDate(
|
||||
getDate(timestamp),
|
||||
(resource as Boat).$id
|
||||
).value;
|
||||
};
|
||||
function onToday() {
|
||||
calendar.value.moveToToday();
|
||||
}
|
||||
function onPrev() {
|
||||
calendar.value.prev();
|
||||
}
|
||||
function onNext() {
|
||||
calendar.value.next();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.q-calendar-scheduler__resource
|
||||
background-color: $primary
|
||||
40
app/pages/signup.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
// NOTE: Password-based registration removed (magic link + OTP only).
|
||||
// This page is a stub — registration is handled by admin invitation.
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page class="flex bg-image flex-center">
|
||||
<q-card v-bind:style="$q.screen.lt.sm ? { width: '80%' } : { width: '30%' }">
|
||||
<q-card-section>
|
||||
<q-img fit="scale-down" src="/oysqn_logo.png" />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-center q-pt-sm">
|
||||
<div class="text-h6">Sign Up</div>
|
||||
<div class="text-body2 q-mt-md">
|
||||
Account registration is managed by the club administrator.
|
||||
Please contact your club admin to request access.
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
<q-btn flat color="primary" label="Back to Login" to="/login" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.bg-image {
|
||||
background-image: url('~/assets/oys_lighthouse.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
77
app/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ public: true, layout: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout>
|
||||
<q-page-container>
|
||||
<q-page padding>
|
||||
<h1>Website Terms and Conditions of Use</h1>
|
||||
|
||||
<h2>1. Terms</h2>
|
||||
<p>
|
||||
By accessing this Website, accessible from https://undock.ca, you are
|
||||
agreeing to be bound by these Website Terms and Conditions of Use and
|
||||
agree that you are responsible for the agreement with any applicable
|
||||
local laws. If you disagree with any of these terms, you are
|
||||
prohibited from accessing this site. The materials contained in this
|
||||
Website are protected by copyright and trade mark law.
|
||||
</p>
|
||||
|
||||
<h2>2. Use License</h2>
|
||||
<p>
|
||||
Permission is granted to temporarily download one copy of the
|
||||
materials on undock.ca's Website for personal, non-commercial
|
||||
transitory viewing only. This is the grant of a license, not a
|
||||
transfer of title, and under this license you may not:
|
||||
</p>
|
||||
<ul>
|
||||
<li>modify or copy the materials;</li>
|
||||
<li>use the materials for any commercial purpose or for any public display;</li>
|
||||
<li>attempt to reverse engineer any software contained on undock.ca's Website;</li>
|
||||
<li>remove any copyright or other proprietary notations from the materials; or</li>
|
||||
<li>transferring the materials to another person or "mirror" the materials on any other server.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Disclaimer</h2>
|
||||
<p>
|
||||
All the materials on undock.ca's Website are provided "as is". undock.ca makes no
|
||||
warranties, may it be expressed or implied, therefore negates all other warranties.
|
||||
</p>
|
||||
|
||||
<h2>4. Limitations</h2>
|
||||
<p>
|
||||
undock.ca or its suppliers will not be hold accountable for any damages that will
|
||||
arise with the use or inability to use the materials on undock.ca's Website.
|
||||
</p>
|
||||
|
||||
<h2>5. Revisions and Errata</h2>
|
||||
<p>
|
||||
The materials appearing on undock.ca's Website may include technical, typographical,
|
||||
or photographic errors. undock.ca will not promise that any of the materials in this
|
||||
Website are accurate, complete, or current.
|
||||
</p>
|
||||
|
||||
<h2>6. Links</h2>
|
||||
<p>
|
||||
undock.ca has not reviewed all of the sites linked to its Website and is not
|
||||
responsible for the contents of any such linked site.
|
||||
</p>
|
||||
|
||||
<h2>7. Site Terms of Use Modifications</h2>
|
||||
<p>
|
||||
undock.ca may revise these Terms of Use for its Website at any time without prior notice.
|
||||
</p>
|
||||
|
||||
<h2>8. Your Privacy</h2>
|
||||
<p>Please read our <a href="/privacy-policy">Privacy Policy.</a></p>
|
||||
|
||||
<h2>9. Governing Law</h2>
|
||||
<p>
|
||||
Any claim related to undock.ca's Website shall be governed by the laws of ca without
|
||||
regards to its conflict of law provisions.
|
||||
</p>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
15
app/plugins/appwrite.client.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
import { initAppwriteClient } from '~/utils/appwrite';
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
const endpoint = config.public.appwriteEndpoint as string;
|
||||
const projectId = config.public.appwriteProjectId as string;
|
||||
if (!endpoint || !projectId) {
|
||||
console.error('Appwrite config missing — check NUXT_PUBLIC_APPWRITE_ENDPOINT and NUXT_PUBLIC_APPWRITE_PROJECT_ID');
|
||||
return;
|
||||
}
|
||||
initAppwriteClient(endpoint, projectId);
|
||||
const authStore = useAuthStore();
|
||||
await authStore.init();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ID, account, functions, teams } from 'boot/appwrite';
|
||||
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
|
||||
import { ID, account, functions, teams } from '~/utils/appwrite';
|
||||
import { ExecutionMethod, type Models } from 'appwrite';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBoatStore } from './boat';
|
||||
import { useReservationStore } from './reservation';
|
||||
@@ -36,35 +36,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
);
|
||||
};
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
await account.create(ID.unique(), email, password);
|
||||
return await login(email, password);
|
||||
}
|
||||
async function login(email: string, password: string) {
|
||||
await account.createEmailPasswordSession(email, password);
|
||||
await init();
|
||||
}
|
||||
|
||||
async function createTokenSession(email: string) {
|
||||
return await account.createEmailToken(ID.unique(), email);
|
||||
}
|
||||
|
||||
async function googleLogin() {
|
||||
await account.createOAuth2Session(
|
||||
OAuthProvider.Google,
|
||||
'https://oys.undock.ca',
|
||||
'https://oys.undock.ca/login'
|
||||
async function createMagicURLSession(email: string) {
|
||||
return await account.createMagicURLToken(
|
||||
ID.unique(),
|
||||
email,
|
||||
window.location.origin + '/auth/callback'
|
||||
);
|
||||
await init();
|
||||
}
|
||||
|
||||
async function discordLogin() {
|
||||
await account.createOAuth2Session(
|
||||
OAuthProvider.Discord,
|
||||
'https://oys.undock.ca',
|
||||
'https://oys.undock.ca/login'
|
||||
);
|
||||
await init();
|
||||
}
|
||||
|
||||
async function tokenLogin(userId: string, token: string) {
|
||||
@@ -72,6 +53,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await init();
|
||||
}
|
||||
|
||||
async function magicURLLogin(userId: string, secret: string) {
|
||||
await account.updateMagicURLSession(userId, secret);
|
||||
await init();
|
||||
}
|
||||
|
||||
function getUserNameById(id: string | undefined | null): string {
|
||||
if (!id) return 'No User';
|
||||
try {
|
||||
@@ -96,11 +82,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
} catch (e) {
|
||||
console.error('Failed to get username. Error: ' + e);
|
||||
}
|
||||
return userNames.value[id];
|
||||
return userNames.value[id] ?? 'Unknown';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
return account.deleteSession('current').then((currentUser.value = null));
|
||||
return account.deleteSession('current').then(() => {
|
||||
currentUser.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateName(name: string) {
|
||||
@@ -112,13 +100,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
currentUser,
|
||||
getUserNameById,
|
||||
hasRequiredRole,
|
||||
register,
|
||||
updateName,
|
||||
login,
|
||||
googleLogin,
|
||||
discordLogin,
|
||||
createTokenSession,
|
||||
createMagicURLSession,
|
||||
tokenLogin,
|
||||
magicURLLogin,
|
||||
logout,
|
||||
init,
|
||||
};
|
||||
@@ -1,28 +1,9 @@
|
||||
import { Models } from 'appwrite';
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ref } from 'vue';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
|
||||
// const boatSource = null;
|
||||
|
||||
export interface Boat extends Models.Document {
|
||||
$id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
imgSrc?: string;
|
||||
iconSrc?: string;
|
||||
bookingAvailable: boolean;
|
||||
requiredCerts: string[];
|
||||
maxPassengers: number;
|
||||
defects: {
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
detail?: string;
|
||||
}[];
|
||||
}
|
||||
export { type Boat } from '~/utils/boat.types';
|
||||
|
||||
export const useBoatStore = defineStore('boat', () => {
|
||||
const boats = ref<Boat[]>([]);
|
||||
@@ -33,7 +14,7 @@ export const useBoatStore = defineStore('boat', () => {
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.boat
|
||||
);
|
||||
boats.value = response.documents as Boat[];
|
||||
boats.value = response.documents as unknown as Boat[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch boats', error);
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Boat } from './boat';
|
||||
import { Timestamp, today } from '@quasar/quasar-ui-qcalendar';
|
||||
import { Interval } from './schedule.types';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { today } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { Interval } from '~/utils/schedule.types';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { useReservationStore } from './reservation';
|
||||
import { LoadingTypes } from 'src/utils/misc';
|
||||
import type { LoadingTypes } from '~/utils/misc';
|
||||
import { useRealtimeStore } from './realtime';
|
||||
|
||||
export const useIntervalStore = defineStore('interval', () => {
|
||||
const intervals = ref(new Map<string, Interval>()); // Intervals by DocID
|
||||
const dateStatus = ref(new Map<string, LoadingTypes>()); // State of load by date
|
||||
const intervals = ref(new Map<string, Interval>());
|
||||
const dateStatus = ref(new Map<string, LoadingTypes>());
|
||||
|
||||
const selectedDate = ref<string>(today());
|
||||
|
||||
const reservationStore = useReservationStore();
|
||||
|
||||
const realtimeStore = useRealtimeStore();
|
||||
|
||||
realtimeStore.register(
|
||||
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.interval}.documents`,
|
||||
(response) => {
|
||||
const payload = response.payload as Interval;
|
||||
const payload = response.payload as unknown as Interval;
|
||||
if (!payload.$id) return;
|
||||
if (
|
||||
response.events.includes('databases.*.collections.*.documents.*.delete')
|
||||
@@ -88,11 +88,11 @@ export const useIntervalStore = defineStore('interval', () => {
|
||||
'start',
|
||||
new Date(dateString + 'T23:59').toISOString()
|
||||
),
|
||||
Query.limit(50), // We are asuming that we won't have more than 50 intervals per day.
|
||||
Query.limit(50),
|
||||
]
|
||||
);
|
||||
response.documents.forEach((d) =>
|
||||
intervals.value.set(d.$id, d as Interval)
|
||||
intervals.value.set(d.$id, d as unknown as Interval)
|
||||
);
|
||||
dateStatus.value.set(dateString, 'loaded');
|
||||
console.info(`Loaded ${response.documents.length} intervals from server`);
|
||||
@@ -110,11 +110,12 @@ export const useIntervalStore = defineStore('interval', () => {
|
||||
ID.unique(),
|
||||
interval
|
||||
);
|
||||
intervals.value.set(response.$id, response as Interval);
|
||||
intervals.value.set(response.$id, response as unknown as Interval);
|
||||
} catch (e) {
|
||||
console.error('Error creating Interval: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateInterval = async (interval: Interval) => {
|
||||
try {
|
||||
if (interval.$id) {
|
||||
@@ -124,7 +125,7 @@ export const useIntervalStore = defineStore('interval', () => {
|
||||
interval.$id,
|
||||
{ ...interval, $id: undefined }
|
||||
);
|
||||
intervals.value.set(response.$id, response as Interval);
|
||||
intervals.value.set(response.$id, response as unknown as Interval);
|
||||
console.info(`Saved Interval: ${interval.$id}`);
|
||||
} else {
|
||||
console.error('Update interval called without an ID');
|
||||
@@ -133,6 +134,7 @@ export const useIntervalStore = defineStore('interval', () => {
|
||||
console.error('Error updating Interval: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteInterval = async (id: string) => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Ref, ref } from 'vue';
|
||||
import { IntervalTemplate } from './schedule.types';
|
||||
import type { Ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import type { IntervalTemplate } from '~/utils/schedule.types';
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { ID, Models } from 'appwrite';
|
||||
import { arrayToTimeTuples } from 'src/utils/schedule';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
import { ID } from 'appwrite';
|
||||
import { arrayToTimeTuples } from '~/utils/schedule';
|
||||
|
||||
export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||
|
||||
const getIntervalTemplates = (): Ref<IntervalTemplate[]> => {
|
||||
// Should subscribe to get new intervaltemplates when they are created
|
||||
if (!intervalTemplates.value) fetchIntervalTemplates();
|
||||
return intervalTemplates;
|
||||
};
|
||||
@@ -20,14 +21,13 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.intervalTemplate
|
||||
);
|
||||
intervalTemplates.value = response.documents.map(
|
||||
(d: Models.Document): IntervalTemplate => {
|
||||
intervalTemplates.value = response.documents.map((d): IntervalTemplate => {
|
||||
const doc = d as unknown as { timeTuple: string[] } & Models.Document;
|
||||
return {
|
||||
...d,
|
||||
timeTuples: arrayToTimeTuples(d.timeTuple),
|
||||
} as IntervalTemplate;
|
||||
}
|
||||
);
|
||||
...doc,
|
||||
timeTuples: arrayToTimeTuples(doc.timeTuple),
|
||||
} as unknown as IntervalTemplate;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch timeblock templates', error);
|
||||
}
|
||||
@@ -41,11 +41,12 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
ID.unique(),
|
||||
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||
);
|
||||
intervalTemplates.value.push(response as IntervalTemplate);
|
||||
intervalTemplates.value.push(response as unknown as IntervalTemplate);
|
||||
} catch (e) {
|
||||
console.error('Error updating IntervalTemplate: ' + e);
|
||||
console.error('Error creating IntervalTemplate: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteIntervalTemplate = async (id: string) => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
@@ -60,6 +61,7 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
console.error('Error deleting IntervalTemplate: ' + e);
|
||||
}
|
||||
};
|
||||
|
||||
const updateIntervalTemplate = async (
|
||||
template: IntervalTemplate,
|
||||
id: string
|
||||
@@ -79,8 +81,10 @@ export const useIntervalTemplateStore = defineStore('intervalTemplate', () => {
|
||||
? b
|
||||
: ({
|
||||
...response,
|
||||
timeTuples: arrayToTimeTuples(response.timeTuple),
|
||||
} as IntervalTemplate)
|
||||
timeTuples: arrayToTimeTuples(
|
||||
(response as unknown as { timeTuple: string[] }).timeTuple
|
||||
),
|
||||
} as unknown as IntervalTemplate)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error updating IntervalTemplate: ' + e);
|
||||
@@ -18,16 +18,4 @@ export const useMemberProfileStore = defineStore('memberProfile', {
|
||||
state: () => ({
|
||||
...getSampleData(),
|
||||
}),
|
||||
|
||||
// getters: {
|
||||
// doubleCount (state) {
|
||||
// return state.counter * 2;
|
||||
// }
|
||||
// },
|
||||
|
||||
// actions: {
|
||||
// increment () {
|
||||
// this.counter++;
|
||||
// }
|
||||
// }
|
||||
});
|
||||
@@ -1,17 +1,16 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { client } from 'src/boot/appwrite';
|
||||
import { Interval } from './schedule.types';
|
||||
import { client } from '~/utils/appwrite';
|
||||
import { ref } from 'vue';
|
||||
import { RealtimeResponseEvent } from 'appwrite';
|
||||
import type { RealtimeResponseEvent } from 'appwrite';
|
||||
|
||||
export const useRealtimeStore = defineStore('realtime', () => {
|
||||
const subscriptions = ref<Map<string, () => void>>(new Map());
|
||||
|
||||
const register = (
|
||||
channel: string,
|
||||
fn: (response: RealtimeResponseEvent<Interval>) => void
|
||||
fn: (response: RealtimeResponseEvent<unknown>) => void
|
||||
) => {
|
||||
if (subscriptions.value.has(channel)) return; // Already subscribed. But maybe different callback fn?
|
||||
if (subscriptions.value.has(channel)) return;
|
||||
subscriptions.value.set(channel, client.subscribe(channel, fn));
|
||||
};
|
||||
|
||||
@@ -20,26 +20,26 @@ function getSampleData(): ReferenceEntry[] {
|
||||
content: `Its hard to imagine that a modern 27 foot sailboat with a classic look, superb
|
||||
stability, and easy to manage rig is such a fast boat. 123-126 PHRF. No longer do you
|
||||
have to substitute speed for comfort, or own separate boats for racing and cruising. The
|
||||
8\’ long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
|
||||
8' long cockpit seats 4 to 5 comfortably. Below deck you can sleep 5. And with head and
|
||||
stove, the J/27 is the perfect weekend cruiser.
|
||||
|
||||
Fun and Fast. There are some impressive victories to back this up, but that doesn\’t
|
||||
Fun and Fast. There are some impressive victories to back this up, but that doesn't
|
||||
tell the whole story. The J/27 is fun and responsive. Nothing is more exhilarating than
|
||||
popping the J/27\’s kite in a good breeze for a downhill sleigh ride. 15+ knots planing
|
||||
off the wave-tops is easy. And most importantly, this off-wind speed doesn\’t sacrifice
|
||||
popping the J/27's kite in a good breeze for a downhill sleigh ride. 15+ knots planing
|
||||
off the wave-tops is easy. And most importantly, this off-wind speed doesn't sacrifice
|
||||
upwind performance. Going to windward in the J/27 is a dream, it has the solid, balanced
|
||||
"feel" of a traditional keelboat. The J/27 points higher and goes faster than many 30-35
|
||||
footers!
|
||||
|
||||
One-Design Racing. Even more fun is sailing a one-design race around the buoys. The
|
||||
J/27\’s close-windedness makes it very tactical, as even 5 degree wind shifts bring
|
||||
significant gains. Then off wind, you quickly learn to play gibe angles as the boat\’s
|
||||
J/27's close-windedness makes it very tactical, as even 5 degree wind shifts bring
|
||||
significant gains. Then off wind, you quickly learn to play gibe angles as the boat's
|
||||
acceleration gains you valuable ground on the competition. The J/27 is remarkably agile
|
||||
and responsive in lighter winds, which is unusual for a boat that feels so solid.
|
||||
|
||||
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it\’s
|
||||
effortless and you can\’t be written off as being wet and uncomfortable. Design is the
|
||||
difference. It\’s all done from a cockpit which holds several people more than is possible
|
||||
All-Day Comfort. Sailing past larger boats is always satisfying... especially when it's
|
||||
effortless and you can't be written off as being wet and uncomfortable. Design is the
|
||||
difference. It's all done from a cockpit which holds several people more than is possible
|
||||
on other 27-footers. Correctly angled backrests and decks at elbow level provide restful
|
||||
and secure seating. Harken mainsheet, vang, traveler, and backstay systems; four Barient
|
||||
winches; a beautiful double spreader, tapered, fractional rig spar by Hall . . . make
|
||||
@@ -57,14 +57,14 @@ function getSampleData(): ReferenceEntry[] {
|
||||
starboard is a comfortable quarter berth. Enough room below for a family of four or a
|
||||
couple for a nice weekend romp to your favorite sailing anchorage.
|
||||
|
||||
Durable and Stable. The J/27\’s secure big boat feel is created by concentrating 1530
|
||||
Durable and Stable. The J/27's secure big boat feel is created by concentrating 1530
|
||||
pounds of lead very low in the keel while using high strength to eight ratio laminates
|
||||
in the hull. Unidirectional E-glass on either side of pre-sealed Baltek CK57 aircraft
|
||||
grade, Lloyd\’s approved, end grain balsa sandwich construction means superior torsion and
|
||||
grade, Lloyd's approved, end grain balsa sandwich construction means superior torsion and
|
||||
impact resistance. Light ends, low freeboard, and the low center of gravity of a lead keel
|
||||
coupled with low wetted surface and a generous sailplan of 362 sq. ft. achieves exceptional
|
||||
sail area and stability relative to displacement. Hence, sparkling performance in both
|
||||
light and heavy air...something that doesn\’t happen with iron keels and box-like hulls.
|
||||
light and heavy air...something that doesn't happen with iron keels and box-like hulls.
|
||||
|
||||
Strong Class Strict Rules. The J/27 Class Association, owner driven and over 190 boats
|
||||
strong, sail in North American, Midwinter, and Regional championships. A superb J/27 Class
|
||||
@@ -113,15 +113,11 @@ export const useReferenceStore = defineStore('reference', {
|
||||
|
||||
getters: {
|
||||
getCategory(state) {
|
||||
(category: string) => {
|
||||
return (category: string) => {
|
||||
return state.allItems.filter((c) => c.category === category);
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
// increment () {
|
||||
// this.counter++;
|
||||
// }
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { Reservation } from './schedule.types';
|
||||
import { ComputedRef, computed, reactive } from 'vue';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import type { Reservation } from '~/utils/schedule.types';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { AppwriteIds, databases } from '~/utils/appwrite';
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { date, useQuasar } from 'quasar';
|
||||
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||
import { LoadingTypes } from 'src/utils/misc';
|
||||
import type { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||
import type { LoadingTypes } from '~/utils/misc';
|
||||
import { useAuthStore } from './auth';
|
||||
import { isPast } from 'src/utils/schedule';
|
||||
import { isPast } from '~/utils/schedule';
|
||||
import { useRealtimeStore } from './realtime';
|
||||
|
||||
export const useReservationStore = defineStore('reservation', () => {
|
||||
@@ -22,7 +24,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
realtimeStore.register(
|
||||
`databases.${AppwriteIds.databaseId}.collections.${AppwriteIds.collection.reservation}.documents`,
|
||||
(response) => {
|
||||
const payload = response.payload as Reservation;
|
||||
const payload = response.payload as unknown as Reservation;
|
||||
if (payload.$id) {
|
||||
if (
|
||||
response.events.includes(
|
||||
@@ -39,7 +41,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
// Fetch reservations for a specific date range
|
||||
|
||||
const fetchReservationsForDateRange = async (
|
||||
start: string = today(),
|
||||
end: string = start
|
||||
@@ -62,7 +64,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
);
|
||||
|
||||
response.documents.forEach((d) =>
|
||||
reservations.set(d.$id, d as Reservation)
|
||||
reservations.set(d.$id, d as unknown as Reservation)
|
||||
);
|
||||
setDateLoaded(startDate, endDate, 'loaded');
|
||||
} catch (error) {
|
||||
@@ -70,6 +72,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
setDateLoaded(startDate, endDate, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const getReservationById = async (id: string) => {
|
||||
try {
|
||||
const response = await databases.getDocument(
|
||||
@@ -77,7 +80,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
AppwriteIds.collection.reservation,
|
||||
id
|
||||
);
|
||||
return response as Reservation;
|
||||
return response as unknown as Reservation;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservation: ', error);
|
||||
}
|
||||
@@ -103,10 +106,10 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
reservation
|
||||
);
|
||||
}
|
||||
reservations.set(response.$id, response as Reservation);
|
||||
userReservations.set(response.$id, response as Reservation);
|
||||
reservations.set(response.$id, response as unknown as Reservation);
|
||||
userReservations.set(response.$id, response as unknown as Reservation);
|
||||
console.info('Reservation booked: ', response);
|
||||
return response as Reservation;
|
||||
return response as unknown as Reservation;
|
||||
} catch (e) {
|
||||
console.error('Error creating Reservation: ' + e);
|
||||
throw e;
|
||||
@@ -157,7 +160,6 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Set the loading state for dates
|
||||
const setDateLoaded = (start: Date, end: Date, state: LoadingTypes) => {
|
||||
if (start > end) return [];
|
||||
let curDate = start;
|
||||
@@ -179,7 +181,6 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
return unloaded;
|
||||
};
|
||||
|
||||
// Get reservations by date and optionally filter by boat
|
||||
const getReservationsByDate = (
|
||||
searchDate: string,
|
||||
boat?: string
|
||||
@@ -203,7 +204,6 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Get conflicting reservations for a resource within a time range
|
||||
const getConflictingReservations = (
|
||||
resource: string,
|
||||
start: Date,
|
||||
@@ -217,7 +217,6 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Check if a resource has time overlap
|
||||
const isResourceTimeOverlapped = (
|
||||
resource: string,
|
||||
start: Date,
|
||||
@@ -226,7 +225,6 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
return getConflictingReservations(resource, start, end).length > 0;
|
||||
};
|
||||
|
||||
// Check if a reservation overlaps with existing reservations
|
||||
const isReservationOverlapped = (res: Reservation): boolean => {
|
||||
return isResourceTimeOverlapped(
|
||||
res.resource,
|
||||
@@ -244,7 +242,7 @@ export const useReservationStore = defineStore('reservation', () => {
|
||||
[Query.equal('user', authStore.currentUser.$id)]
|
||||
);
|
||||
response.documents.forEach((d) =>
|
||||
userReservations.set(d.$id, d as Reservation)
|
||||
userReservations.set(d.$id, d as unknown as Reservation)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reservations for user: ', error);
|
||||
41
app/utils/appwrite.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Client, Account, Databases, Functions, ID, Teams } from 'appwrite';
|
||||
|
||||
const client = new Client();
|
||||
|
||||
function initAppwriteClient(endpoint: string, projectId: string) {
|
||||
client.setEndpoint(endpoint).setProject(projectId);
|
||||
}
|
||||
|
||||
type AppwriteIDConfig = {
|
||||
databaseId: string;
|
||||
collection: {
|
||||
boat: string;
|
||||
reservation: string;
|
||||
interval: string;
|
||||
intervalTemplate: string;
|
||||
// task, taskTags, skillTags — parked; collections not yet created in bab_prod
|
||||
};
|
||||
function: {
|
||||
userinfo: string;
|
||||
};
|
||||
};
|
||||
|
||||
const AppwriteIds: AppwriteIDConfig = {
|
||||
databaseId: 'bab_prod',
|
||||
collection: {
|
||||
boat: 'boat',
|
||||
reservation: 'reservation',
|
||||
interval: 'interval',
|
||||
intervalTemplate: 'intervalTemplate',
|
||||
},
|
||||
function: {
|
||||
userinfo: 'userinfo',
|
||||
},
|
||||
};
|
||||
|
||||
const account = new Account(client);
|
||||
const databases = new Databases(client);
|
||||
const functions = new Functions(client);
|
||||
const teams = new Teams(client);
|
||||
|
||||
export { client, account, databases, functions, teams, ID, AppwriteIds, initAppwriteClient };
|
||||
20
app/utils/boat.types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export interface Boat extends Models.Document {
|
||||
$id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
imgSrc?: string;
|
||||
iconSrc?: string;
|
||||
bookingAvailable: boolean;
|
||||
requiredCerts: string[];
|
||||
maxPassengers: number;
|
||||
defects: {
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
detail?: string;
|
||||
}[];
|
||||
}
|
||||
@@ -2,8 +2,6 @@ export function getNewId(): string {
|
||||
return [...Array(20)]
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join('');
|
||||
// Trivial placeholder
|
||||
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
||||
}
|
||||
|
||||
export type LoadingTypes = 'loaded' | 'pending' | 'error' | undefined;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
export type Link = {
|
||||
name: string;
|
||||
to: string;
|
||||
to?: string;
|
||||
icon: string;
|
||||
front_links?: boolean;
|
||||
enabled?: boolean;
|
||||
@@ -11,7 +11,7 @@ export type Link = {
|
||||
requiredRoles?: string[];
|
||||
};
|
||||
|
||||
export const links = <Link[]>[
|
||||
export const links: Link[] = [
|
||||
{
|
||||
name: 'Home',
|
||||
to: '/',
|
||||
@@ -40,36 +40,9 @@ export const links = <Link[]>[
|
||||
front_links: true,
|
||||
enabled: true,
|
||||
sublinks: [
|
||||
{
|
||||
name: 'My View',
|
||||
to: '/schedule/list',
|
||||
icon: 'list',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Book',
|
||||
to: '/schedule/book',
|
||||
icon: 'more_time',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Calendar',
|
||||
to: '/schedule/view',
|
||||
icon: 'calendar_month',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'Manage',
|
||||
to: '/schedule/manage',
|
||||
icon: 'edit_calendar',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
color: 'accent',
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
},
|
||||
{ name: 'My View', to: '/schedule/list', icon: 'list', front_links: false, enabled: true },
|
||||
{ name: 'Book', to: '/schedule/book', icon: 'more_time', front_links: false, enabled: true },
|
||||
{ name: 'Calendar', to: '/schedule/view', icon: 'calendar_month', front_links: false, enabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -94,28 +67,46 @@ export const links = <Link[]>[
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
to: '/task',
|
||||
icon: 'build',
|
||||
front_links: true,
|
||||
enabled: false,
|
||||
name: 'Manage',
|
||||
icon: 'tune',
|
||||
enabled: true,
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
color: 'negative',
|
||||
sublinks: [
|
||||
{
|
||||
name: 'Schedule',
|
||||
to: '/schedule/manage',
|
||||
icon: 'edit_calendar',
|
||||
front_links: false,
|
||||
enabled: true,
|
||||
color: 'accent',
|
||||
requiredRoles: ['Schedule Admins'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function useNavLinks() {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function hasRole(roles: string[] | undefined) {
|
||||
if (roles === undefined) return true;
|
||||
const hasRole = authStore.hasRequiredRole(roles);
|
||||
return hasRole;
|
||||
return authStore.hasRequiredRole(roles);
|
||||
}
|
||||
export const enabledLinks = links
|
||||
|
||||
const enabledLinks = links
|
||||
.filter((link) => link.enabled)
|
||||
.map((link) => {
|
||||
if (link.sublinks) {
|
||||
link.sublinks = link.sublinks.filter(
|
||||
return {
|
||||
...link,
|
||||
sublinks: link.sublinks.filter(
|
||||
(sublink) => sublink.enabled && hasRole(sublink.requiredRoles)
|
||||
);
|
||||
),
|
||||
};
|
||||
}
|
||||
return link;
|
||||
});
|
||||
|
||||
return { enabledLinks };
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { date } from 'quasar';
|
||||
import { Boat } from 'src/stores/boat';
|
||||
import {
|
||||
Interval,
|
||||
IntervalTemplate,
|
||||
TimeTuple,
|
||||
} from 'src/stores/schedule.types';
|
||||
import type { Boat } from '~/utils/boat.types';
|
||||
import type { Interval, IntervalTemplate, TimeTuple } from '~/utils/schedule.types';
|
||||
|
||||
export function arrayToTimeTuples(arr: string[]) {
|
||||
const timeTuples: TimeTuple[] = [];
|
||||
for (let i = 0; i < arr.length; i += 2) {
|
||||
timeTuples.push([arr[i], arr[i + 1]]);
|
||||
timeTuples.push([arr[i]!, arr[i + 1]!]);
|
||||
}
|
||||
return timeTuples;
|
||||
}
|
||||
@@ -24,20 +20,18 @@ export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||
};
|
||||
})
|
||||
).map((t) => {
|
||||
return { ...t, start: t.start.split(' ')[1], end: t.end.split(' ')[1] };
|
||||
return { ...t, start: t.start.split(' ')[1]!, end: t.end.split(' ')[1]! };
|
||||
});
|
||||
}
|
||||
|
||||
export function intervalsOverlapped(
|
||||
blocks: Interval[] | Interval[]
|
||||
): Interval[] {
|
||||
export function intervalsOverlapped(blocks: Interval[]): Interval[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
blocks
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||
.reduce((acc: Interval[], block, i, arr) => {
|
||||
if (i > 0 && block.start < arr[i - 1].end)
|
||||
acc.push(arr[i - 1], block);
|
||||
if (i > 0 && block.start < arr[i - 1]!.end)
|
||||
acc.push(arr[i - 1]!, block);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
@@ -47,36 +41,27 @@ export function intervalsOverlapped(
|
||||
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||
return tuples.map((t) => Object.assign([], t));
|
||||
}
|
||||
export function copyIntervalTemplate(
|
||||
template: IntervalTemplate
|
||||
): IntervalTemplate {
|
||||
|
||||
export function copyIntervalTemplate(template: IntervalTemplate): IntervalTemplate {
|
||||
return {
|
||||
...template,
|
||||
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInterval(
|
||||
resource: Boat,
|
||||
time: TimeTuple,
|
||||
blockDate: string
|
||||
): Interval {
|
||||
/* When the time zone offset is absent, date-only forms are interpreted
|
||||
as a UTC time and date-time forms are interpreted as local time. */
|
||||
const result = {
|
||||
export function buildInterval(resource: Boat, time: TimeTuple, blockDate: string): Interval {
|
||||
return {
|
||||
resource: resource.$id,
|
||||
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export const isPast = (itemDate: Date | string): boolean => {
|
||||
if (!(itemDate instanceof Date)) {
|
||||
itemDate = new Date(itemDate);
|
||||
}
|
||||
const currentDate = new Date();
|
||||
return itemDate < currentDate;
|
||||
return itemDate < new Date();
|
||||
};
|
||||
|
||||
export function formatDate(inputDate: string | undefined): string {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Models } from 'appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||
export type Reservation = Interval & {
|
||||
@@ -12,9 +12,6 @@ export type Reservation = Interval & {
|
||||
|
||||
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||
// Within 24 hrs, any available slot
|
||||
/* TODO: Figure out how best to separate out where qcalendar bits should be.
|
||||
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
|
||||
objects in here? */
|
||||
|
||||
export type TimeTuple = [start: string, end: string];
|
||||
|
||||
@@ -22,6 +19,7 @@ export type Interval = Partial<Models.Document> & {
|
||||
resource: string;
|
||||
start: string;
|
||||
end: string;
|
||||
user?: string;
|
||||
};
|
||||
|
||||
export type IntervalTemplate = Partial<Models.Document> & {
|
||||
1
app/utils/version.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = '0.0.0';
|
||||
88
docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Session Handoff: Auth Refactor — Magic Link & Cleanup
|
||||
**Date:** 2026-03-15
|
||||
**Session Duration:** ~1 hour
|
||||
**Session Focus:** Remove Google/Discord OAuth, add magic link login, add About dialog
|
||||
**Context Usage at Handoff:** ~30%
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. Analyzed full auth flow (store, boot, login page, router guard) → no output file, inline analysis
|
||||
2. Removed Google OAuth → deleted `src/components/GoogleOauthComponent.vue`
|
||||
3. Removed Discord OAuth → deleted `src/components/DiscordOauthComponent.vue`
|
||||
4. Removed `googleLogin`, `discordLogin` from auth store → `src/stores/auth.ts`
|
||||
5. Removed `OAuthProvider` import from auth store → `src/stores/auth.ts`
|
||||
6. Added `createMagicURLSession()` to auth store (calls `account.createMagicURLToken`) → `src/stores/auth.ts`
|
||||
7. Added `magicURLLogin()` to auth store (calls `account.updateMagicURLSession`) → `src/stores/auth.ts`
|
||||
8. Updated `LoginPage.vue` — removed OAuth component imports/usage, added "Send Magic Link" button, added `onMounted` handler to detect magic link callback params and call `magicURLLogin` → `src/pages/LoginPage.vue`
|
||||
9. Added "About" item to left drawer → `src/components/LeftDrawer.vue` — opens a Quasar Dialog with app name, version, description
|
||||
10. Converted `src/version.js` → `src/version.ts` to eliminate TS hint
|
||||
11. Updated `generate-version.js` to write to `src/version.ts` → `generate-version.js`
|
||||
12. Fixed stale import in `SignupPage.vue` (`src/version.js` → `src/version`) → `src/pages/SignupPage.vue`
|
||||
13. Fixed `LeftDrawer.vue` import path `boot/appwrite` → `src/boot/appwrite` (was a TS module resolution error)
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- `.env.local` not being picked up by `quasar dev`: user interrupted the fix (was about to add `require('dotenv').config(...)` to `quasar.config.js`). **Status: UNRESOLVED.** User stopped this change — may prefer a different approach or wants to investigate themselves.
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- Use `account.updateMagicURLSession(userId, secret)` (not `createSession`) for magic link completion — BECAUSE Appwrite SDK v14 uses a separate method for magic URL vs OTP token sessions. `createSession` is for OTP only.
|
||||
- Magic link callback URL = `window.location.origin + '/login'` — Appwrite appends `?userId=xxx&secret=xxx` and the `onMounted` handler in LoginPage detects and consumes these.
|
||||
- Keep OTP code flow alongside magic link — user did not ask to remove it; both are available.
|
||||
- About dialog placed in LeftDrawer (not a separate page) — appropriate pattern for simple info display in a mobile app.
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
|
||||
- Appwrite SDK version: `^14.0.1`
|
||||
- `@quasar/app-vite` version: `^1.9.1`
|
||||
- App version string source: `src/version.ts`, written by `generate-version.js` (takes version as CLI arg)
|
||||
- Dev server port: `4000` (set in `quasar.config.js` `devServer.strictport`)
|
||||
|
||||
## Conditional Logic Established
|
||||
|
||||
- IF magic link callback detected (`query.userId && query.secret` in route on LoginPage mount) THEN call `magicURLLogin(userId, secret)` BECAUSE this uses `updateMagicURLSession` which is the correct Appwrite v14 API.
|
||||
- IF user enters email and clicks "Send Code" THEN OTP flow runs (6-digit code emailed, entered in second input field).
|
||||
- IF user enters email and clicks "Send Magic Link" THEN magic link email sent; user clicks link; page reloads at `/login?userId=xxx&secret=xxx`; `onMounted` auto-completes login.
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `src/stores/auth.ts` | Modified | Removed `googleLogin`, `discordLogin`, `OAuthProvider` import; added `createMagicURLSession`, `magicURLLogin` |
|
||||
| `src/pages/LoginPage.vue` | Modified | Removed OAuth components; added magic link button and `onMounted` callback handler |
|
||||
| `src/components/LeftDrawer.vue` | Modified | Added "About" menu item that opens info dialog with version; fixed boot import path |
|
||||
| `src/components/GoogleOauthComponent.vue` | Deleted | No longer used |
|
||||
| `src/components/DiscordOauthComponent.vue` | Deleted | No longer used |
|
||||
| `src/version.ts` | Renamed (was `.js`) | Eliminates TypeScript implicit-any hint |
|
||||
| `src/pages/SignupPage.vue` | Modified | Updated `version.js` import to `version` |
|
||||
| `generate-version.js` | Modified | Now writes to `src/version.ts` instead of `src/version.js` |
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **First**: Verify magic link flow end-to-end in dev environment (send link, click it, confirm auto-login works)
|
||||
2. **Then**: Resolve `.env.local` not being picked up — options are: (a) add `require('dotenv').config({ path: '.env.local' })` to top of `quasar.config.js`, or (b) use `.env` instead of `.env.local`, or (c) investigate if `@quasar/app-vite` 1.9.x has a bug
|
||||
3. **Then**: Check if `SignupPage.vue` / `register()` flow is still intended — it creates email+password accounts but the login page only offers passwordless flows; this is an inconsistency
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
|
||||
- [ ] Should the OTP (6-digit code) flow be kept, or replaced entirely by magic link? — impacts LoginPage UX
|
||||
- [ ] Should the SignupPage (email+password registration) be removed in favour of magic link only? — impacts `src/pages/SignupPage.vue`, `src/stores/auth.ts` `register()`, router `/signup` route
|
||||
- [ ] Should "Forgot Password?" link be removed from LoginPage now that magic link is the primary flow? — it was already removed from LoginPage in this session (not present in current code)
|
||||
- [ ] `.env.local` fix approach — user stopped the `dotenv` approach; confirm preferred method
|
||||
|
||||
## Assumptions That Need Validation
|
||||
|
||||
- ASSUMED: `window.location.origin` is correct for magic link callback URL in all deployment environments — validate that prod URL matches what Appwrite Console has whitelisted as a redirect domain
|
||||
- ASSUMED: Appwrite project has magic URL tokens enabled — validate in Appwrite Console → Auth settings
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- `src/components/GoogleOauthComponent.vue` — deleted
|
||||
- `src/components/DiscordOauthComponent.vue` — deleted
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `src/stores/auth.ts` — primary auth logic, fully refactored this session
|
||||
- `src/pages/LoginPage.vue` — magic link + OTP UI, modified this session
|
||||
- `src/boot/appwrite.ts` — contains duplicate `login()` function (email/password) that may be dead code post-refactor
|
||||
- `quasar.config.js` — if resolving `.env.local` issue
|
||||
101
docs/archive/handoffs/handoff-2026-03-15-build-fixes.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Session Handoff: Build Fixes & Dev Environment
|
||||
**Date:** 2026-03-15
|
||||
**Session Duration:** ~2 hours
|
||||
**Session Focus:** Resolve all TypeScript/ESLint build errors from dependency updates; fix dev server startup
|
||||
**Context Usage at Handoff:** Medium
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. Fixed 30 TypeScript errors (14 files) → build now passes with 0 errors
|
||||
2. Fixed 12 ESLint problems (6 errors, 6 warnings) → 0 remaining
|
||||
3. Fixed `quasar dev` startup error (`FlatESLint is not a constructor`) → downgraded ESLint v10→v9
|
||||
4. Fixed missing Appwrite env vars in `.env.local` → app connects to backend on dev
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- **Build**: CLEAN — `yarn quasar build` exits 0, no TS or ESLint errors
|
||||
- **Dev server**: FUNCTIONAL — `quasar dev` starts without errors; ESLint inline checking via `vite-plugin-checker` is restored
|
||||
- **Runtime**: UNTESTED this session — app has not been manually tested against the dev Appwrite backend
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- **`as unknown as Type` for all Appwrite store casts** — CONFIRMED: Appwrite v23 made `DefaultDocument` strict; it no longer overlaps domain types, so the double-cast is required. Applied to: `boat.ts`, `interval.ts`, `intervalTemplate.ts`, `reservation.ts`, `task.ts`
|
||||
- **ESLint downgraded v10.0.3 → v9.39.4** — CONFIRMED: `vite-plugin-checker` v0.12.0 calls `FlatESLint` which was merged back into `ESLint` in v10; v9 preserves the API. Also downgraded `@eslint/js` (v10→v9) and `eslint-plugin-vue` (v10→v9)
|
||||
- **`getWeekdaySkips` removed** — CONFIRMED: removed from `@quasar/quasar-ui-qcalendar` API; `createDayList` now takes `weekdays` array directly as 4th param (previously took `weekdaySkips` computed value)
|
||||
- **`subtasks` removed from TaskCardComponent template** — ASSUMED SAFE: `Task` type has no `subtasks` field; template refs were dead code. See open question.
|
||||
- **`no-debugger: 'off'`** — CONFIRMED: hardcoded because `process` is not available in ESLint globals when linting `.js` files (config file context)
|
||||
- **`.env.local` variable names corrected** — CONFIRMED: file had `VITE_APPWRITE_ENDPOINT` / `VITE_APPWRITE_PROJECT`; `appwrite.ts` reads `VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT`
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
|
||||
- TypeScript errors at session start: 30 (across 14 files)
|
||||
- ESLint problems at session start: 12 (6 errors, 6 warnings)
|
||||
- TypeScript errors at session end: 0
|
||||
- ESLint problems at session end: 0
|
||||
- ESLint: v10.0.3 → v9.39.4
|
||||
- `@eslint/js`: v10 → v9
|
||||
- `eslint-plugin-vue`: v10 → v9
|
||||
- `register-service-worker`: newly added (was missing from package.json)
|
||||
|
||||
## Conditional Logic Established
|
||||
|
||||
- IF Appwrite SDK returns `DefaultDocument` THEN cast via `as unknown as DomainType` BECAUSE v23 `DefaultDocument` is strict and no longer assignable to domain types that extend `Partial<Models.Document>`
|
||||
- IF `vite-plugin-checker` is v0.12.x THEN ESLint must be v9.x BECAUSE v0.12.x uses `FlatESLint` constructor removed in ESLint v10
|
||||
- IF `createDayList` is called from qcalendar THEN pass `weekdays` array as 4th arg directly BECAUSE `getWeekdaySkips` was removed from the qcalendar public API
|
||||
- IF `.env.local` is updated THEN variable names must match `import.meta.env.VITE_APPWRITE_API_ENDPOINT` / `VITE_APPWRITE_API_PROJECT` as read in `src/boot/appwrite.ts`
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `src/stores/boat.ts` | Modified | `as unknown as Boat[]` |
|
||||
| `src/stores/interval.ts` | Modified | `as unknown as Interval` (3 places) |
|
||||
| `src/stores/intervalTemplate.ts` | Modified | Map callback cast + `as unknown as IntervalTemplate` (3 places); `timeTuple` cast |
|
||||
| `src/stores/reservation.ts` | Modified | `as unknown as Reservation` (5 places) |
|
||||
| `src/stores/task.ts` | Modified | `as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task` (5 places) |
|
||||
| `src/stores/sampledata/schedule.ts` | Modified | `id`→`$id`, `blocks`→`timeTuples`, removed `reservationDate` |
|
||||
| `src/components/boat/BoatPreviewComponent.vue` | Modified | `boat.id` → `boat.$id` |
|
||||
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | Modified | `block.id`→`block.$id`; `NodeJS.Timeout`→`ReturnType<typeof setInterval>`; ternary→if/else |
|
||||
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Modified | Removed `getWeekdaySkips` import+computed; `createDayList` now passes `weekdays` directly |
|
||||
| `src/components/task/TaskCardComponent.vue` | Modified | Removed `defineProps` explicit import; removed `subtasks` template refs |
|
||||
| `src/components/task/TaskListComponent.vue` | Modified | `task.id` → `task.$id` |
|
||||
| `src/components/task/TaskTableComponent.vue` | Modified | Removed `defineProps` from explicit import |
|
||||
| `src/components/ResourceScheduleViewerComponent.vue` | Modified | Removed `|| undefined`; `catch { }`; removed stale eslint-disable comments |
|
||||
| `src/pages/LoginPage.vue` | Modified | `catch { }` |
|
||||
| `src/pages/schedule/ManageCalendar.vue` | Modified | `block.id` → `block.$id` |
|
||||
| `src/boot/appwrite.ts` | Modified | Removed stale `console.log(API_ENDPOINT)` |
|
||||
| `eslint.config.js` | Modified | `no-debugger` hardcoded to `'off'` |
|
||||
| `quasar.config.ts` | Modified | ESLint checker restored (had been temporarily removed) |
|
||||
| `package.json` / `yarn.lock` | Modified | ESLint v10→v9; `@eslint/js` v10→v9; `eslint-plugin-vue` v10→v9; added `register-service-worker` |
|
||||
| `.env.local` | Modified | Variable names corrected: `VITE_APPWRITE_ENDPOINT`→`VITE_APPWRITE_API_ENDPOINT`, `VITE_APPWRITE_PROJECT`→`VITE_APPWRITE_API_PROJECT`; endpoint URL updated to include `/v1` |
|
||||
| `docs/summaries/handoff-2026-03-15-build-fixes.md` | Created | This file |
|
||||
| `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` | Archived | Superseded by this handoff |
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **First**: Run `quasar dev` and manually test the login flow against the dev Appwrite backend to validate v23 API calls work at runtime
|
||||
2. **Validate**: Boat listing, reservation creation/cancellation, interval loading — confirm no runtime errors from the v23 positional-param deprecations
|
||||
3. **Commit**: Stage all modified files and commit as `"fix: Resolve build errors from dependency updates"` (single clean commit covering all TS/ESLint/qcalendar/env fixes)
|
||||
4. **Optional**: Migrate Appwrite calls from deprecated positional-param style to object-param style (affects all stores — low priority, they still work)
|
||||
5. **Optional**: Add `subtasks?: Task[]` to `Task` interface in `src/stores/task.ts` if that feature is planned
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
|
||||
- [ ] `task.subtasks` removed from `TaskCardComponent` template — should `subtasks?: Task[]` be added to the `Task` interface for future use, or is subtask support not planned?
|
||||
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store call). Migrate now or leave for later?
|
||||
|
||||
## Assumptions That Need Validation
|
||||
|
||||
- ASSUMED: Appwrite v23 positional-param API calls behave identically at runtime to v14 — validate by doing a full login + reservation flow against the dev backend
|
||||
- ASSUMED: `subtasks` in `TaskCardComponent` was dead/future code — no user confirmed this
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- `docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md` — archived; superseded by this file
|
||||
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived; auth work complete
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `src/stores/task.ts` — if adding `subtasks` to Task interface
|
||||
- `src/boot/appwrite.ts` — if migrating to Appwrite v23 object-param style
|
||||
- Any store file (`boat.ts`, `interval.ts`, `reservation.ts`, etc.) — if migrating Appwrite calls
|
||||
114
docs/archive/handoffs/handoff-2026-03-15-dependency-updates.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Session Handoff: Dependency Updates & ESLint Cleanup
|
||||
**Date:** 2026-03-15
|
||||
**Session Focus:** Complete Quasar v1→v2 migration, Appwrite SDK v14→v23 update, ESLint flat config cleanup
|
||||
**Status:** BUILD PASSING — 0 errors, 0 warnings
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
### From prior sessions (captured in archived handoffs):
|
||||
1. Auth flow refactored to passwordless (magic link + OTP), OAuth removed
|
||||
2. Google/Discord OAuth components deleted
|
||||
3. About dialog with version info added → `src/components/LeftDrawer.vue`
|
||||
4. `quasar.config.js` → `quasar.config.ts` (ESM TypeScript)
|
||||
5. `"type": "module"` added to `package.json`
|
||||
6. Yarn 1.x → Yarn 4.13.0
|
||||
7. ESLint legacy `.eslintrc.cjs` → flat config `eslint.config.js`
|
||||
8. QCalendar app extension removed → direct npm package import
|
||||
9. Boot/router/store wrappers updated to `#q-app/wrappers` imports
|
||||
10. Appwrite SDK updated v14.0.1 → v23.0.0
|
||||
11. `globals` package installed; browser + ES2021 globals added to ESLint config
|
||||
|
||||
### This session (build cleanup — all 30 TS errors + 12 ESLint issues resolved):
|
||||
|
||||
**TypeScript `as unknown as` casts** (Appwrite v23 `DefaultDocument` no longer overlaps domain types):
|
||||
- `src/stores/boat.ts:36` — `as unknown as Boat[]`
|
||||
- `src/stores/interval.ts:95,113,127` — `as unknown as Interval`
|
||||
- `src/stores/intervalTemplate.ts` — map callback cast + `as unknown as IntervalTemplate` (3 places)
|
||||
- `src/stores/reservation.ts:65,80,247` — `as unknown as Reservation`
|
||||
- `src/stores/task.ts:53,65,77,109,132` — `as unknown as Task[]`, `TaskTag[]`, `SkillTag[]`, `Task`
|
||||
|
||||
**`.id` → `.$id` fixes** (Appwrite uses `$id`, not `id`):
|
||||
- `src/components/boat/BoatPreviewComponent.vue:7`
|
||||
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:54`
|
||||
- `src/components/task/TaskListComponent.vue:4`
|
||||
- `src/pages/schedule/ManageCalendar.vue:40`
|
||||
- `src/stores/sampledata/schedule.ts:19,29,138` — also `id:` → `$id:` in object literals
|
||||
|
||||
**`defineProps` import conflict** (auto-imported in `<script setup>`, cannot also be explicitly imported):
|
||||
- `src/components/task/TaskCardComponent.vue:20` — removed import; also removed `subtasks` template refs (not in Task type)
|
||||
- `src/components/task/TaskTableComponent.vue:215` — removed `defineProps` from import
|
||||
|
||||
**ESLint fixes:**
|
||||
- `eslint.config.js:52` — `process.env.NODE_ENV === 'production' ? 'error' : 'off'` → `'off'` (process not defined in .js ESLint globals)
|
||||
- `src/components/ResourceScheduleViewerComponent.vue:173` — removed `|| undefined` (always truthy)
|
||||
- `src/components/ResourceScheduleViewerComponent.vue:177` — `catch (e)` → `catch { }` (unused binding)
|
||||
- `src/components/ResourceScheduleViewerComponent.vue:237-247` — removed unused `eslint-disable-next-line` comments
|
||||
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:116` — `NodeJS.Timeout` → `ReturnType<typeof setInterval>`
|
||||
- `src/components/scheduling/boat/BoatScheduleTableComponent.vue:129` — ternary as statement → `if/else`; also destructured `{ direction }` to fix unused `event` hint
|
||||
- `src/pages/LoginPage.vue:131` — `catch (e)` → `catch { }`
|
||||
|
||||
**Other fixes:**
|
||||
- `src-pwa/register-service-worker.ts` — installed `register-service-worker` package (was missing from package.json)
|
||||
- `src/stores/sampledata/schedule.ts:50` — `template.blocks` → `template.timeTuples` (property was renamed)
|
||||
- `src/stores/sampledata/schedule.ts:145` — removed `reservationDate` (not in Reservation type)
|
||||
- `src/stores/intervalTemplate.ts:27` — `d.timeTuple` cast via `as unknown as { timeTuple: string[] }`
|
||||
- `src/stores/intervalTemplate.ts:82` — `response.timeTuple` cast via `as unknown as { timeTuple: string[] }`
|
||||
- `src/components/scheduling/boat/CalendarHeaderComponent.vue` — removed `getWeekdaySkips` (removed from qcalendar API); `createDayList` now takes `weekdays` directly as 4th arg; removed `weekdaySkips` computed
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **`as unknown as Type` pattern** — correct approach for Appwrite v23 `DefaultDocument` casts; v23 made `DefaultDocument` strict, no longer assignable to domain types without double-cast — STATUS: CONFIRMED
|
||||
- **`getWeekdaySkips` removed from qcalendar** — `createDayList` now accepts `weekdays` array directly as 4th param — STATUS: CONFIRMED (verified from ESM source)
|
||||
- **`subtasks` removed from TaskCardComponent template** — `Task` type has no `subtasks` field; feature was dead code — STATUS: ASSUMED safe (see open question)
|
||||
- **`no-debugger: 'off'`** — hardcoded instead of `process.env.NODE_ENV` conditional because `process` is not in ESLint globals for `.js` files — STATUS: CONFIRMED
|
||||
|
||||
## Key Numbers
|
||||
|
||||
- TS errors at session start: 30 (in 14 files)
|
||||
- ESLint errors at session start: 12 (6 errors, 6 warnings)
|
||||
- TS errors at session end: 0
|
||||
- ESLint errors at session end: 0
|
||||
- Packages added: `register-service-worker`
|
||||
|
||||
## Files Modified This Session
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `eslint.config.js` | `no-debugger` rule hardcoded to `'off'` |
|
||||
| `src/components/ResourceScheduleViewerComponent.vue` | 3 ESLint fixes |
|
||||
| `src/components/scheduling/boat/BoatScheduleTableComponent.vue` | `.id`→`.$id`, NodeJS.Timeout, if/else |
|
||||
| `src/components/scheduling/boat/CalendarHeaderComponent.vue` | Removed `getWeekdaySkips`; updated `createDayList` call |
|
||||
| `src/pages/LoginPage.vue` | `catch { }` |
|
||||
| `src/components/boat/BoatPreviewComponent.vue` | `.id`→`.$id` |
|
||||
| `src/components/task/TaskCardComponent.vue` | Removed `defineProps` import + subtasks refs |
|
||||
| `src/components/task/TaskListComponent.vue` | `.id`→`.$id` |
|
||||
| `src/components/task/TaskTableComponent.vue` | Removed `defineProps` from import |
|
||||
| `src/pages/schedule/ManageCalendar.vue` | `.id`→`.$id` |
|
||||
| `src/stores/boat.ts` | `as unknown as Boat[]` |
|
||||
| `src/stores/interval.ts` | `as unknown as Interval` (3 places) |
|
||||
| `src/stores/intervalTemplate.ts` | Multiple cast fixes + `timeTuple` access |
|
||||
| `src/stores/reservation.ts` | `as unknown as Reservation` (multiple) |
|
||||
| `src/stores/sampledata/schedule.ts` | `id`→`$id`, `blocks`→`timeTuples`, removed `reservationDate` |
|
||||
| `src/stores/task.ts` | `as unknown as` for Task/TaskTag/SkillTag |
|
||||
| `package.json` / `yarn.lock` | Added `register-service-worker` |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] `src/components/task/TaskCardComponent.vue` — `subtasks` removed from template. Should `subtasks?: Task[]` be added to the `Task` interface in `task.ts` for future use? OPEN
|
||||
- [ ] Appwrite v23 deprecated positional-param overloads (hints in every store). Should stores be migrated to new object-param style? Low priority — code still works. OPEN
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMED: `subtasks` feature in TaskCardComponent was dead/future code — safe to remove template refs
|
||||
- ASSUMED: `no-debugger: 'off'` is fine for devel branch
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- `docs/archive/handoffs/handoff-2026-03-15-auth-magic-link.md` — archived
|
||||
|
||||
## Next Session
|
||||
|
||||
- Commit all dependency update + build fix changes
|
||||
- Test the app against the dev Appwrite backend (validate v23 API calls work at runtime)
|
||||
- Consider migrating Appwrite calls from deprecated positional-param to object-param style (optional)
|
||||
- Consider adding `subtasks?: Task[]` to `Task` interface if the feature is planned
|
||||
186
docs/archive/handoffs/handoff-2026-03-18-nuxt-migration-plan.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Handoff: Nuxt 3 Migration Plan
|
||||
Date: 2026-03-18
|
||||
Type: Architecture / Planning
|
||||
|
||||
## Decision
|
||||
Migrate bab-app from Quasar 2 + Vue 3 PWA to **Nuxt 3 + Capacitor + FullCalendar**.
|
||||
Retain Appwrite backend unchanged. Auth simplified to magic link / email OTP only.
|
||||
|
||||
## Current Stack (Source of Truth)
|
||||
- Quasar 2 / Vue 3 / TypeScript / Pinia / Appwrite
|
||||
- Build: PWA only (`quasar build -m pwa`)
|
||||
- Appwrite project: `65ede55a213134f2b688` @ `https://appwrite.toal.ca/v1`
|
||||
- Database: `bab_prod`
|
||||
- Collections: boat, reservation, interval, intervalTemplate, task, taskTags, skillTags
|
||||
- 9 Pinia stores, 21 components, 2 layouts, ~20 pages/routes
|
||||
- Auth: email token + magic link + password (vue3-google-login present but UNUSED)
|
||||
|
||||
## Target Stack
|
||||
- **Nuxt 3** (file-based routing, Nitro, Vite)
|
||||
- **nuxt-quasar-ui** module — keeps all Quasar components during migration, de-risks UI rewrite
|
||||
- **@fullcalendar/vue3** — replaces @quasar/quasar-ui-qcalendar
|
||||
- **@capacitor/core** + plugins — camera, geolocation (native mobile future)
|
||||
- **Pinia** — unchanged
|
||||
- **Appwrite SDK** — unchanged, via Nuxt plugin
|
||||
- **Auth**: magic link + email OTP only; remove vue3-google-login
|
||||
|
||||
## Key Architectural Decisions
|
||||
- Use `nuxt-quasar-ui` to avoid a big-bang UI rewrite — keeps q-* components working
|
||||
- File-based routing replaces routes.ts — directory structure maps 1:1 to current routes
|
||||
- Boot file → Nuxt plugin (appwrite.ts becomes plugins/appwrite.ts)
|
||||
- Router guards → Nuxt middleware (auth.ts global middleware)
|
||||
- **PWA only initially** — Capacitor deferred until native camera/GPS features are needed
|
||||
- FullCalendar resource-timeline view for multi-boat scheduling (replaces QCalendarResource)
|
||||
- **FullCalendar non-commercial license confirmed** — key: `CC-Attribution-NonCommercial-NoDerivatives` (OYS is a registered Ontario not-for-profit #000082982)
|
||||
- **Static output** — `nuxt generate`, served via nginx, consistent with current Ansible deploy
|
||||
- **TestFlight / App Store deferred** — revisit when PWA hits UX or capability limits
|
||||
|
||||
## Migration Phases (see plan in this file)
|
||||
|
||||
### Phase 1 — Foundation (1-2 days) ✅ COMPLETE 2026-03-19
|
||||
Scaffold new Nuxt 3 project alongside existing. Do NOT delete old project until Phase 6 complete.
|
||||
|
||||
New project: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/`
|
||||
|
||||
Tasks:
|
||||
- `npx nuxi@latest init bab-app-nuxt`
|
||||
- Add modules: `nuxt-quasar-ui`, `@pinia/nuxt`, `@vite-pwa/nuxt`
|
||||
- Configure `nuxt.config.ts`: Quasar options, Vite aliases, env vars
|
||||
- Migrate `.env.local` vars (VITE_ prefix → NUXT_PUBLIC_ for public vars)
|
||||
- Capacitor: DEFERRED — PWA only until native features needed
|
||||
|
||||
### Phase 2 — Appwrite Plugin + Types (0.5 days)
|
||||
- Copy `src/boot/appwrite.ts` → `plugins/appwrite.ts`
|
||||
- Change export pattern to Nuxt plugin format (`defineNuxtPlugin`)
|
||||
- Provide `$appwrite` via `useNuxtApp()`
|
||||
- Copy all TypeScript type files verbatim
|
||||
|
||||
### Phase 3 — Auth (1 day)
|
||||
- Migrate `stores/auth.ts` → `stores/auth.ts` (Pinia, near-verbatim)
|
||||
- Remove all password auth methods (createEmailPasswordSession, etc.)
|
||||
- Keep: `createMagicURLToken`, `updateMagicURLSession`, `createEmailToken`, `updatePhoneSession`
|
||||
- Remove `vue3-google-login` from package.json
|
||||
- Create `middleware/auth.global.ts` — replaces router/index.ts navigation guard
|
||||
- Public routes: `/login`, `/signup`, `/pwreset`, `/terms-of-service`, `/privacy-policy`
|
||||
- Magic link callback: `pages/auth/callback.vue` — handles `?userId=&secret=` params
|
||||
|
||||
### Phase 4 — Remaining Stores (0.5 days)
|
||||
All stores are framework-agnostic Pinia — direct copy with minor import path updates:
|
||||
- stores/boat.ts
|
||||
- stores/reservation.ts
|
||||
- stores/interval.ts
|
||||
- stores/intervalTemplate.ts
|
||||
- stores/task.ts
|
||||
- stores/reference.ts
|
||||
- stores/realtime.ts
|
||||
- stores/memberProfile.ts
|
||||
|
||||
### Phase 5 — File-Based Routing (Page Scaffold) (0.5 days)
|
||||
Create empty page files matching Nuxt convention. Route mapping:
|
||||
|
||||
| Old path | Nuxt file |
|
||||
|---|---|
|
||||
| `/` | `pages/index.vue` |
|
||||
| `/boat` | `pages/boat.vue` |
|
||||
| `/certification` | `pages/certification.vue` |
|
||||
| `/profile` | `pages/profile.vue` |
|
||||
| `/checklist` | `pages/checklist.vue` |
|
||||
| `/reference` | `pages/reference/index.vue` |
|
||||
| `/reference/:id/view` | `pages/reference/[id]/view.vue` |
|
||||
| `/schedule` | `pages/schedule/index.vue` |
|
||||
| `/schedule/book` | `pages/schedule/book.vue` |
|
||||
| `/schedule/view` | `pages/schedule/view.vue` |
|
||||
| `/schedule/list` | `pages/schedule/list.vue` |
|
||||
| `/schedule/edit/:id` | `pages/schedule/edit/[id].vue` |
|
||||
| `/schedule/manage` | `pages/schedule/manage.vue` |
|
||||
| `/task` | PARKED — task feature not migrated (collections absent from bab_prod) |
|
||||
| `/task/:id/edit` | PARKED |
|
||||
| `/admin/user` | `pages/admin/user.vue` |
|
||||
| `/admin/boat` | `pages/admin/boat.vue` |
|
||||
| `/login` | `pages/login.vue` |
|
||||
| `/signup` | `pages/signup.vue` |
|
||||
| `/pwreset` | `pages/pwreset.vue` |
|
||||
| `/terms-of-service` | `pages/terms-of-service.vue` |
|
||||
| `/privacy-policy` | `pages/privacy-policy.vue` |
|
||||
|
||||
Admin route guard: `definePageMeta({ middleware: ['auth', 'admin'] })` in admin pages.
|
||||
Schedule manage guard: `definePageMeta({ middleware: ['auth', 'schedule-admin'] })`.
|
||||
|
||||
### Phase 6 — Layouts + Global Components (1 day)
|
||||
- `layouts/default.vue` — replaces MainLayout.vue (q-layout, q-header, LeftDrawer, BottomNav)
|
||||
- `layouts/admin.vue` — replaces AdminLayout.vue
|
||||
- Migrate components verbatim first; replace Quasar components later if needed:
|
||||
- components/BottomNavComponent.vue
|
||||
- components/LeftDrawer.vue (OPEN: was listed in agent findings but not in original glob — confirm file exists)
|
||||
- components/ToolbarComponent.vue
|
||||
|
||||
### Phase 7 — Page Migration (3-5 days)
|
||||
Migrate pages in this order (lowest to highest complexity):
|
||||
|
||||
1. Public pages: login, signup, pwreset, terms, privacy (simple forms)
|
||||
2. Profile, Certification, Checklist, Boat (read-only/simple CRUD)
|
||||
3. Reference pages
|
||||
4. Admin pages (UserAdmin, BoatAdmin — q-table heavy)
|
||||
5. Schedule pages (most complex — last)
|
||||
6. Task pages — PARKED. Skip until bab_prod collections (task, taskTags, skillTags) are created.
|
||||
Affected: stores/task.ts, components/task/*, pages/task/*, BottomNav task link, navlinks.ts task entry.
|
||||
|
||||
### Phase 8 — FullCalendar (2-3 days)
|
||||
Replace QCalendar with FullCalendar Vue 3.
|
||||
|
||||
Packages:
|
||||
```
|
||||
@fullcalendar/vue3
|
||||
@fullcalendar/core
|
||||
@fullcalendar/resource-timeline # multi-boat resource view
|
||||
@fullcalendar/daygrid # month/day grid
|
||||
@fullcalendar/timegrid # week/day time grid
|
||||
@fullcalendar/interaction # drag and drop
|
||||
@fullcalendar/list # list view
|
||||
```
|
||||
|
||||
Mapping:
|
||||
- `ResourceScheduleViewerComponent.vue` → new `FullCalendarResourceView.vue`
|
||||
- Resources = boats (one row per boat)
|
||||
- Events = reservations + intervals
|
||||
- DnD for admin interval management (replaces IntervalTemplateComponent drag behavior)
|
||||
- `CalendarHeaderComponent.vue` — FullCalendar has built-in header toolbar, simplify or remove
|
||||
- `BoatScheduleTableComponent.vue` — replace with FullCalendar list/grid view
|
||||
|
||||
FullCalendar license: open-source plugins are free; resource-timeline requires **FullCalendar Premium** license ($0 for open-source/non-commercial projects — confirm this applies, otherwise budget ~$200/yr).
|
||||
|
||||
OPEN: Confirm if OYS Borrow a Boat qualifies for FullCalendar open-source license.
|
||||
|
||||
### Phase 9 — Capacitor (1 day)
|
||||
- `npx cap add ios` / `npx cap add android`
|
||||
- Install plugins (not wired to features yet, just scaffold):
|
||||
- `@capacitor/camera`
|
||||
- `@capacitor/geolocation`
|
||||
- Create `composables/useCamera.ts` and `composables/useGeolocation.ts`
|
||||
- Detect Capacitor.isNativePlatform() and fall back to browser APIs on web
|
||||
- Update `nuxt.config.ts` ssr: false (Capacitor requires SPA mode)
|
||||
- capacitor.config.ts: `webDir: '.output/public'`
|
||||
|
||||
### Phase 10 — CI/CD (0.5 days)
|
||||
Update `.gitea/workflows/build.yaml`:
|
||||
- Replace `quasar build -m pwa` with `nuxt build`
|
||||
- Output dir: `.output/public/` (static) or `.output/` (server)
|
||||
- Ansible deploy: serve `.output/public` as static site (nginx), same as current
|
||||
|
||||
## Open Questions
|
||||
- OPEN: Does OYS qualify for FullCalendar non-commercial license? (resource-timeline is premium)
|
||||
- OPEN: Confirm `LeftDrawer.vue` exists in src/layouts/ or src/components/ — agent referenced it but not in initial glob
|
||||
- OPEN: Is Capacitor native app publishing (App Store / Play Store) in scope, or just Capacitor for future native API access with PWA as primary delivery?
|
||||
- ASSUMED: SSR is not needed — Appwrite client SDK runs browser-side; deploy as SPA/static
|
||||
- ASSUMED: Nuxt deployed as static output (not Node server) — consistent with current nginx/Ansible deploy
|
||||
|
||||
## Effort Estimate
|
||||
| Phase | Days |
|
||||
|---|---|
|
||||
| 1-4 (Foundation, Plugin, Auth, Stores) | 3 |
|
||||
| 5-6 (Routing, Layouts) | 1.5 |
|
||||
| 7 (Page migration) | 4 |
|
||||
| 8 (FullCalendar) | 2.5 |
|
||||
| 9 (Capacitor) | 1 |
|
||||
| 10 (CI/CD) | 0.5 |
|
||||
| **Total** | **~12.5 days** |
|
||||
@@ -0,0 +1,82 @@
|
||||
# Session Handoff: Nuxt Migration — Phases 3 & 4 Complete
|
||||
**Date:** 2026-03-19
|
||||
**Session Focus:** Auth store, middleware, callback page, and remaining Pinia stores
|
||||
**Context Usage at Handoff:** ~50%
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. Phase 3 — Auth complete
|
||||
2. Phase 4 — All remaining stores complete (except `task.ts`, still parked)
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- **Migration plan**: 10-phase plan. Phases 1–4 marked complete. Phase 5 (Pages/Components) is next.
|
||||
- **New project**: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` — stores and middleware in place. No pages migrated yet (other than `auth/callback.vue`).
|
||||
- **Old project**: `/home/ptoal/Dev/mobile-projects/bab-app/` — untouched. Do not delete until Phase 6.
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- **Magic link redirect URL**: changed from `/login` to `/auth/callback` — dedicated handler, cleaner separation. CONFIRMED.
|
||||
- **realtime.ts generic type**: changed from `RealtimeResponseEvent<Interval>` (incorrect) to `RealtimeResponseEvent<unknown>` — original typed the callback fn parameter too narrowly since multiple channels use the same store. CONFIRMED.
|
||||
- **Boat interface not re-exported from boat.ts**: `boat.ts` re-exports `Boat` from `~/utils/boat.types` via `export { type Boat }` to preserve import compatibility with components that do `import { Boat } from '~/stores/boat'`. CONFIRMED.
|
||||
|
||||
## Key Numbers
|
||||
|
||||
- **7 stores created**: `auth`, `boat`, `reservation`, `interval`, `intervalTemplate`, `reference`, `memberProfile`
|
||||
- **2 middleware/pages created**: `middleware/auth.global.ts`, `pages/auth/callback.vue`
|
||||
- **1 plugin updated**: `plugins/appwrite.client.ts`
|
||||
- **Task stores**: still 0 — `task`, `taskTags`, `skillTags` collections not in `bab_prod`
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `bab-app-nuxt/app/stores/auth.ts` | Created | Magic link + OTP only; `register()`/`login()` removed; boat/reservation init wired |
|
||||
| `bab-app-nuxt/app/stores/boat.ts` | Created | Re-exports `Boat` from `~/utils/boat.types`; near-verbatim port |
|
||||
| `bab-app-nuxt/app/stores/reservation.ts` | Created | Near-verbatim port; imports updated |
|
||||
| `bab-app-nuxt/app/stores/interval.ts` | Created | Near-verbatim port; `Boat` imported from `~/utils/boat.types` |
|
||||
| `bab-app-nuxt/app/stores/intervalTemplate.ts` | Created | Near-verbatim port |
|
||||
| `bab-app-nuxt/app/stores/reference.ts` | Created | Verbatim port (static data, no Appwrite) |
|
||||
| `bab-app-nuxt/app/stores/realtime.ts` | Created | Generic type corrected to `unknown` |
|
||||
| `bab-app-nuxt/app/stores/memberProfile.ts` | Created | Verbatim port (static data, no Appwrite) |
|
||||
| `bab-app-nuxt/app/middleware/auth.global.ts` | Created | Global Nuxt route guard; public via `to.meta.public`; roles via `to.meta.requiredRoles` |
|
||||
| `bab-app-nuxt/app/pages/auth/callback.vue` | Created | Handles `?userId=&secret=` magic link params; redirects to `/` |
|
||||
| `bab-app-nuxt/app/plugins/appwrite.client.ts` | Modified | `authStore.init()` wired in |
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **Execute Phase 5 — Pages & Components**
|
||||
- Identify pages in old `src/pages/` — migrate one at a time
|
||||
- Key pages: `LoginPage.vue`, `IndexPage.vue`, `BoatPage.vue`, `ProfilePage.vue`, `CertificationPage.vue`, `ChecklistPage.vue`
|
||||
- Schedule pages: `SchedulePageView.vue`, `ScheduleIndexPage.vue`, `BoatReservationPage.vue`, `BoatScheduleView.vue`, `ListReservationsPage.vue`, `ModifyBoatReservation.vue`, `ManageCalendar.vue`
|
||||
- Admin pages: `UserAdminPage.vue`, `BoatAdminPage.vue`
|
||||
- Static pages: `TermsOfServicePage.vue`, `PrivacyPolicyPage.vue`, `ErrorNotFound.vue`
|
||||
- Add `definePageMeta({ public: true })` to: `login.vue`, `signup.vue`, `pwreset.vue`, `terms-of-service.vue`, `privacy-policy.vue`, `auth/callback.vue` (already done)
|
||||
- Add `definePageMeta({ requiredRoles: ['Schedule Admins'] })` to `schedule/manage.vue`
|
||||
- Add `definePageMeta({ requiredRoles: ['admin'] })` to all `admin/*.vue` pages
|
||||
2. **Execute Phase 6 — Layout**
|
||||
- Migrate `MainLayout.vue` and `AdminLayout.vue`
|
||||
- Confirm `LeftDrawer.vue` exists in `src/components/` (ASSUMED — not yet confirmed)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
|
||||
- [ ] **OPEN**: What pages are in `src/pages/` — exact list not yet read (read on demand per CLAUDE.md rule 6)
|
||||
|
||||
## Assumptions
|
||||
|
||||
- ASSUMED: `LeftDrawer.vue` exists in `src/components/`. Verify before Phase 6.
|
||||
- ASSUMED: `nuxt generate` (static) is sufficient. Still unvalidated.
|
||||
- ASSUMED: `LoginPage.vue` in old app handles both magic link and OTP login UI — new app needs `pages/login.vue` built from scratch with `definePageMeta({ public: true })`.
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- All stores in `bab-app-nuxt/app/stores/` — just created, content known
|
||||
- `src/stores/auth.ts`, `src/stores/boat.ts`, etc. — fully migrated; do not re-read
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `docs/summaries/handoff-2026-03-19-nuxt-migration-phases-3-4.md` (this file)
|
||||
- `src/pages/` — read one page at a time per processing protocol
|
||||
- `src/layouts/MainLayout.vue` — needed for Phase 6 planning
|
||||
- `bab-app-nuxt/app/stores/auth.ts` — if login page needs store shape reference
|
||||
16
docs/context/archive-rules.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Archive Rules
|
||||
|
||||
## Raw File Archival
|
||||
|
||||
After creating a Source Document Summary for any raw file:
|
||||
1. Move the raw file to `docs/archive/`
|
||||
2. Record the move in the source summary's header: `Archived From: [original path]`
|
||||
3. Do not read from `docs/archive/` unless the user explicitly says "go back to the original [filename]"
|
||||
|
||||
## Summary Lifecycle Rules
|
||||
|
||||
1. **Session handoffs expire**: After a new handoff is written, the previous handoff moves to `docs/archive/handoffs/`. Only the latest handoff stays in `docs/summaries/`.
|
||||
2. **Decision records persist**: Decision records (DR-*) stay in `docs/summaries/` permanently — they are institutional memory.
|
||||
3. **Source summaries persist**: Source document summaries stay until the project ends — they replace raw documents.
|
||||
4. **Analysis summaries**: Keep only the latest version. If re-run, the new one replaces the old (archive the old one).
|
||||
5. **Maximum active summaries**: If `docs/summaries/` exceeds 15 files, consolidate older source summaries into a single `project-digest.md` and archive the originals.
|
||||
23
docs/context/processing-protocol.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Document Processing Protocol
|
||||
|
||||
Use this whenever you need to process multiple documents or large files.
|
||||
|
||||
## For 1-3 Short Documents (< 2K words each)
|
||||
|
||||
Read sequentially. After each document, write a Source Document Summary (Template 1 from `templates/claude-templates.md`) to disk. Then proceed with work using summaries only.
|
||||
|
||||
## For 4+ Documents OR Any Document > 2K Words
|
||||
|
||||
**Step 1:** List all documents with file sizes. Present to user for prioritization.
|
||||
|
||||
**Step 2:** Process each document individually:
|
||||
- Read one document
|
||||
- Extract into Source Document Summary format
|
||||
- Write to `./docs/summaries/source-[filename].md`
|
||||
- Release the document from active consideration before reading the next
|
||||
|
||||
**Step 3:** After all documents are processed, read only the summaries to form your working context.
|
||||
|
||||
**Step 4:** Cross-reference summaries for contradictions or dependencies. Note these explicitly.
|
||||
|
||||
**Step 5:** Proceed with the actual task using summaries as your reference.
|
||||
18
docs/context/subagent-rules.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Subagent Deployment Rules
|
||||
|
||||
## When to Use Subagent vs. Main Agent
|
||||
|
||||
| Situation | Approach | Why |
|
||||
|-----------|----------|-----|
|
||||
| Reading/analyzing documents | Subagent | Keeps source content out of main context |
|
||||
| Research and competitive analysis | Subagent | Heavy reading, return summary only |
|
||||
| Writing deliverables | Main agent | Needs full decision-making context |
|
||||
| Schema/architecture design | Main agent | Needs holistic project understanding |
|
||||
| Code generation | Subagent | Isolated implementation, return result |
|
||||
| Review and QA | Subagent | Fresh perspective, no bias from writing |
|
||||
|
||||
## Output Requirements
|
||||
|
||||
Subagent output must conform to the Output Contracts in `templates/claude-templates.md`. No free-form prose returns.
|
||||
|
||||
Optimal subagent return size: 1,000-2,000 tokens of structured summary. Longer returns consume main agent context without proportional benefit.
|
||||
106
docs/summaries/handoff-2026-03-19-nuxt-migration-phases-1-2.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Session Handoff: Nuxt Migration — Phases 1 & 2 Complete
|
||||
**Date:** 2026-03-19
|
||||
**Session Duration:** ~2 hours
|
||||
**Session Focus:** Framework selection, migration planning, and execution of Phases 1 & 2 of Quasar → Nuxt 3 migration
|
||||
**Context Usage at Handoff:** ~60%
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. Framework analysis (React Native vs Flutter vs Next/Nuxt) → decision: Nuxt 3 + Capacitor (deferred) + FullCalendar
|
||||
2. Migration plan created → `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` (now the active plan reference)
|
||||
3. Phase 1 — Foundation complete → `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/`
|
||||
4. Phase 2 — Appwrite plugin + types complete → `app/utils/`, `app/plugins/`
|
||||
5. Integration tests written and passing (11/11) → `tests/appwrite-connection.test.ts`
|
||||
6. Task feature parked → removed from AppwriteIds and tests
|
||||
|
||||
## Exact State of Work in Progress
|
||||
|
||||
- **Migration plan**: 10-phase plan in `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md`. Phases 1–2 marked complete. Phase 3 (Auth) is next.
|
||||
- **New project**: `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` — scaffolded and running. Dev server confirmed clean on `localhost:3000`.
|
||||
- **Old project**: `/home/ptoal/Dev/mobile-projects/bab-app/` — untouched, still operational. Do not delete until Phase 6 is complete.
|
||||
|
||||
## Decisions Made This Session
|
||||
|
||||
- **Nuxt 3 + nuxt-quasar-ui** — keeps all Quasar (`q-*`) components working; avoids big-bang UI rewrite. CONFIRMED.
|
||||
- **FullCalendar non-commercial license** — key `CC-Attribution-NonCommercial-NoDerivatives` confirmed valid. OYS is Ontario not-for-profit #000082982. CONFIRMED.
|
||||
- **PWA only initially** — Capacitor deferred until native camera/GPS features are actively needed. CONFIRMED.
|
||||
- **Static output** — `nuxt generate` → `.output/public/` served via nginx. Consistent with existing Ansible deploy. CONFIRMED.
|
||||
- **TestFlight/App Store deferred** — revisit when PWA hits UX or capability limits. CONFIRMED.
|
||||
- **Auth: magic link + email OTP only** — password auth and Google (`vue3-google-login`) removed. CONFIRMED.
|
||||
- **Task feature parked** — collections `task`, `taskTags`, `skillTags` absent from `bab_prod`. Not migrated until Appwrite collections are created. CONFIRMED.
|
||||
|
||||
## Key Numbers Generated or Discovered This Session
|
||||
|
||||
- **11/11 tests passing** — backend connection verified
|
||||
- **4 collections confirmed reachable** — `boat`, `reservation`, `interval`, `intervalTemplate` (return 401 unauthenticated = exist and require auth)
|
||||
- **3 collections absent** — `task`, `taskTags`, `skillTags` return 404 from `bab_prod`
|
||||
- **Appwrite project ID**: `65ede55a213134f2b688`
|
||||
- **Appwrite endpoint**: `https://appwrite.toal.ca/v1`
|
||||
- **Database ID**: `bab_prod`
|
||||
- **~12 days** total estimated migration effort (unchanged)
|
||||
- **Nuxt version**: 4.4.2 (installed as `nuxt@^4.4.2`)
|
||||
- **nuxt-quasar-ui version**: 3.0.1
|
||||
- **fontIcons config** must be an array `['material-icons']`, not a string — bug if passed as string (iterates characters)
|
||||
|
||||
## Conditional Logic Established
|
||||
|
||||
- IF task feature is needed, THEN create collections `task`, `taskTags`, `skillTags` in Appwrite dashboard first, THEN migrate `stores/task.ts`, `components/task/*`, `pages/task/*`
|
||||
- IF Capacitor is added later, THEN change `nuxt.config.ts` `webDir` to `.output/public` and run `npx cap add ios && npx cap add android`
|
||||
- IF App Store publishing is pursued, THEN use TestFlight (iOS) + Play Store internal testing track, NOT full public store listing (30–50 user club app)
|
||||
|
||||
## Files Created or Modified
|
||||
|
||||
| File Path | Action | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `/home/ptoal/Dev/mobile-projects/bab-app-nuxt/` | Created | New Nuxt 4.4.2 project root |
|
||||
| `bab-app-nuxt/nuxt.config.ts` | Created | ssr:false, nuxt-quasar-ui, @pinia/nuxt, @vite-pwa/nuxt, PWA manifest, runtimeConfig |
|
||||
| `bab-app-nuxt/.env.local` | Created | `NUXT_PUBLIC_APPWRITE_ENDPOINT`, `NUXT_PUBLIC_APPWRITE_PROJECT_ID` |
|
||||
| `bab-app-nuxt/public/icons/` | Created | Copied from old project — all PWA icon sizes |
|
||||
| `bab-app-nuxt/app/utils/appwrite.ts` | Created | Appwrite client + services + AppwriteIds (task collections excluded) |
|
||||
| `bab-app-nuxt/app/utils/boat.types.ts` | Created | `Boat` interface (extracted so schedule.ts can depend on it without the store) |
|
||||
| `bab-app-nuxt/app/utils/schedule.types.ts` | Created | `Interval`, `Reservation`, `IntervalTemplate`, `TimeTuple` |
|
||||
| `bab-app-nuxt/app/utils/misc.ts` | Created | `getNewId()`, `LoadingTypes` |
|
||||
| `bab-app-nuxt/app/utils/schedule.ts` | Created | Schedule utilities; `date` from quasar kept; imports updated to `~/utils/*` |
|
||||
| `bab-app-nuxt/app/plugins/appwrite.client.ts` | Created | Stub plugin; auth store init wired in Phase 3 |
|
||||
| `bab-app-nuxt/vitest.config.ts` | Created | Vitest with `loadEnv` to pick up `.env.local` |
|
||||
| `bab-app-nuxt/tests/appwrite-connection.test.ts` | Created | 11 integration tests verifying backend connectivity |
|
||||
| `bab-app-nuxt/package.json` | Modified | Added `test` and `test:watch` scripts |
|
||||
| `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` | Modified | Added confirmed decisions, updated Phase 1 complete, task parked in Phase 7 |
|
||||
|
||||
## What the NEXT Session Should Do
|
||||
|
||||
1. **First**: Read `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` for the full 10-phase plan and current state
|
||||
2. **Then**: Execute **Phase 3 — Auth**
|
||||
- Migrate `src/stores/auth.ts` → `bab-app-nuxt/app/stores/auth.ts`
|
||||
- Strip password auth methods; keep magic link + email OTP only
|
||||
- Remove `vue3-google-login` from `bab-app-nuxt/package.json`
|
||||
- Create `app/middleware/auth.global.ts` (replaces old router navigation guard in `src/router/index.ts`)
|
||||
- Create `app/pages/auth/callback.vue` — handles magic link redirect `?userId=&secret=` params
|
||||
- Wire `authStore.init()` call into `app/plugins/appwrite.client.ts`
|
||||
3. **Then**: Execute **Phase 4 — Remaining Stores** (all are near-verbatim Pinia ports)
|
||||
- `stores/boat.ts` — import `Boat` from `~/utils/boat.types` instead of defining inline
|
||||
- `stores/reservation.ts`, `interval.ts`, `intervalTemplate.ts`, `reference.ts`, `realtime.ts`, `memberProfile.ts`
|
||||
- Skip `stores/task.ts` (parked)
|
||||
|
||||
## Open Questions Requiring User Input
|
||||
|
||||
- [ ] **Appwrite `task`/`taskTags`/`skillTags` collections** — will these ever be created in `bab_prod`? Determines whether task feature gets migrated at all, or is permanently dropped.
|
||||
|
||||
## Assumptions That Need Validation
|
||||
|
||||
- ASSUMED: `nuxt generate` (static) is sufficient — no SSR/server-side rendering needed. Validate: all Appwrite calls are client-side only (no server routes needed).
|
||||
- ASSUMED: `LeftDrawer.vue` exists in `src/components/` (referenced in codebase exploration but not confirmed by direct file read). Verify before Phase 6.
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- `src/boot/appwrite.ts` — fully migrated; summarized above
|
||||
- `src/utils/misc.ts`, `src/utils/schedule.ts`, `src/stores/schedule.types.ts` — fully migrated; summarized above
|
||||
- `templates/claude-templates.md` — not needed next session
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `docs/summaries/handoff-2026-03-18-nuxt-migration-plan.md` — active migration plan with all phases
|
||||
- `bab-app-nuxt/app/utils/appwrite.ts` — current AppwriteIds shape (needed for store migration)
|
||||
- `bab-app-nuxt/app/plugins/appwrite.client.ts` — needs auth init wired in Phase 3
|
||||
- `src/stores/auth.ts` — source for Phase 3 migration (read once, then discard)
|
||||
- `src/router/index.ts` — source for middleware logic (read once, then discard)
|
||||
112
docs/summaries/handoff-2026-03-19-nuxt-migration-phases-3-5.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Session Handoff: Nuxt Migration — Phases 3–5 Complete
|
||||
**Date:** 2026-03-19
|
||||
**Session Focus:** Auth, all stores, all pages, all layouts, all components
|
||||
**Context at Handoff:** High — recommend fresh session for Phase 6+
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
1. Phase 3 — Auth (stores/auth.ts, middleware, callback page, plugin)
|
||||
2. Phase 4 — All 7 non-task stores
|
||||
3. Phase 5 — All pages, layouts, and non-task components
|
||||
|
||||
## Exact State of bab-app-nuxt
|
||||
|
||||
### Layouts
|
||||
- `app/layouts/default.vue` — MainLayout port; reads `route.meta.title` for header title
|
||||
- `app/layouts/admin.vue` — AdminLayout port; tabs updated to `/admin/user` and `/admin/boat`
|
||||
- `app/app.vue` — Updated to `<NuxtLayout><NuxtPage /></NuxtLayout>`
|
||||
|
||||
### Pages (all created)
|
||||
| Nuxt Path | Old Path | Notes |
|
||||
|-----------|----------|-------|
|
||||
| `pages/index.vue` | `IndexPage.vue` | Uses `useNavLinks()` composable |
|
||||
| `pages/login.vue` | `LoginPage.vue` | `layout: false`; magic link `onMounted` removed (→ `auth/callback.vue`) |
|
||||
| `pages/auth/callback.vue` | (new) | Handles `?userId=&secret=` from magic link |
|
||||
| `pages/boat.vue` | `BoatPage.vue` | |
|
||||
| `pages/profile.vue` | `ProfilePage.vue` | |
|
||||
| `pages/certification.vue` | `CertificationPage.vue` | |
|
||||
| `pages/checklist.vue` | `ChecklistPage.vue` | |
|
||||
| `pages/schedule.vue` | `SchedulePageView.vue` | Parent wrapper; `<NuxtPage />` |
|
||||
| `pages/schedule/index.vue` | `ScheduleIndexPage.vue` | |
|
||||
| `pages/schedule/book.vue` | `BoatReservationPage.vue` | |
|
||||
| `pages/schedule/view.vue` | `BoatScheduleView.vue` | |
|
||||
| `pages/schedule/list.vue` | `ListReservationsPage.vue` | |
|
||||
| `pages/schedule/edit/[id].vue` | `ModifyBoatReservation.vue` | |
|
||||
| `pages/schedule/manage.vue` | `ManageCalendar.vue` | `requiredRoles: ['Schedule Admins']` |
|
||||
| `pages/reference.vue` | `ReferencePage.vue` | Parent wrapper |
|
||||
| `pages/reference/index.vue` | `ReferenceIndexPage.vue` | |
|
||||
| `pages/reference/reference/[id]/view.vue` | `ReferenceItemPage.vue` | Double `reference` path preserved from old routes |
|
||||
| `pages/admin/user.vue` | `UserAdminPage.vue` | `layout: 'admin'`, `requiredRoles: ['admin']` |
|
||||
| `pages/admin/boat.vue` | `BoatAdminPage.vue` | `layout: 'admin'`, `requiredRoles: ['admin']` |
|
||||
| `pages/signup.vue` | `SignupPage.vue` | **STUB** — password auth removed; shows "contact admin" |
|
||||
| `pages/pwreset.vue` | `ResetPassword.vue` | **STUB** — password auth removed |
|
||||
| `pages/terms-of-service.vue` | `TermsOfServicePage.vue` | `layout: false` |
|
||||
| `pages/privacy-policy.vue` | `PrivacyPolicyPage.vue` | `layout: false` |
|
||||
| `pages/[...slug].vue` | `ErrorNotFound.vue` | Catch-all 404 |
|
||||
|
||||
### Components (all created under `app/components/`)
|
||||
- `boat/BoatComponent.vue` — stub
|
||||
- `boat/BoatPickerComponent.vue`
|
||||
- `boat/BoatPreviewComponent.vue`
|
||||
- `CertificationComponent.vue`
|
||||
- `ReferencePreviewComponent.vue`
|
||||
- `BoatReservationComponent.vue`
|
||||
- `ResourceScheduleViewerComponent.vue` — abandoned/retained for reference
|
||||
- `scheduling/NavigationBar.vue`
|
||||
- `scheduling/ReservationCardComponent.vue` — named route updated to `/schedule/edit/${id}`
|
||||
- `scheduling/BoatSelection.vue` — empty stub
|
||||
- `scheduling/IntervalTemplateComponent.vue`
|
||||
- `scheduling/boat/BoatScheduleTableComponent.vue`
|
||||
- `scheduling/boat/CalendarHeaderComponent.vue`
|
||||
- `LeftDrawer.vue` — uses `useNavLinks()` composable; logout via `authStore.logout()`
|
||||
- `BottomNavComponent.vue`
|
||||
|
||||
### Utils added
|
||||
- `app/utils/navlinks.ts` — `useNavLinks()` composable (replaces module-level `enabledLinks`)
|
||||
- `app/utils/version.ts` — `APP_VERSION = '0.0.0'`
|
||||
- `app/assets/` — All assets copied from old project
|
||||
|
||||
### Skipped (task feature parked)
|
||||
- `task/TaskEditPage.vue`, `task/TaskPage.vue`, `admin/TaskAdminPage.vue`
|
||||
- `task/TaskEditComponent.vue`, `task/TaskListComponent.vue`, `task/TaskCardComponent.vue`, `task/TaskTableComponent.vue`
|
||||
- `NewPasswordComponent.vue` (password auth removed)
|
||||
|
||||
## Key Decisions Made This Session
|
||||
|
||||
- **ToolbarComponent dropped** — layout `default.vue` handles title via `route.meta.title`. Pages use `definePageMeta({ title: '...' })` instead.
|
||||
- **Public pages pattern** — `definePageMeta({ public: true, layout: false })` for all auth-less full-layout pages
|
||||
- **Magic link redirect** — `/auth/callback` (not `/login`) — matches Phase 3 decision
|
||||
- **signup.vue / pwreset.vue as stubs** — password auth removed; stubs show redirect message. CONFIRMED.
|
||||
- **navlinks.ts → composable** — original had module-level `useAuthStore()` which would break in Nuxt; converted to `useNavLinks()` composable
|
||||
- **Nuxt named routes not used** — all navigation uses explicit paths (`/schedule/edit/${id}` not `{ name: 'edit-reservation' }`) to avoid Nuxt route name collisions
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
|
||||
- [x] **RESOLVED**: `signup.vue` — admin-only invite is permanent. `signup.vue` stub is the final state; self-service registration will not be built.
|
||||
|
||||
## What NEXT Session Should Do
|
||||
|
||||
1. **Phase 6 — Build & Test**
|
||||
- Run `yarn dev` in `bab-app-nuxt/` — verify dev server starts clean
|
||||
- Navigate to `/login` — verify page renders
|
||||
- Test login flow (magic link or OTP)
|
||||
- Navigate through key pages post-login
|
||||
- Fix any TypeScript or import errors that surface
|
||||
2. **Phase 7 — nuxt.config.ts QCalendar registration**
|
||||
- `@quasar/quasar-ui-qcalendar` is imported directly in components — verify this works in Nuxt (no plugin registration needed if imported directly)
|
||||
- If calendar components don't render, add CSS import to `nuxt.config.ts`
|
||||
3. **Phase 8 — Deploy** (if Phase 6 passes)
|
||||
- `yarn generate` → verify `.output/public/` built correctly
|
||||
- Update Ansible deploy playbook if needed (path may differ from old `dist/`)
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `bab-app-nuxt/nuxt.config.ts` — check QCalendar CSS import, verify PWA config
|
||||
- `bab-app-nuxt/package.json` — verify no missing deps
|
||||
- Any error output from `yarn dev`
|
||||
|
||||
## What NOT to Re-Read
|
||||
|
||||
- All files in `bab-app-nuxt/app/` — just created; content known
|
||||
- All files in `bab-app/src/` — fully migrated; do not re-read
|
||||
82
docs/summaries/handoff-2026-03-19-nuxt-phase6-build-fixes.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Session Handoff: Nuxt Migration — Phase 6 Complete
|
||||
**Date:** 2026-03-19
|
||||
**Session Focus:** Build & TypeScript error fixes
|
||||
**Context at Handoff:** Low — clean build, ready for Phase 7+
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
**Phase 6 — Build & Test**
|
||||
1. Added missing dependency `@quasar/quasar-ui-qcalendar@4.1.2` to `bab-app-nuxt/package.json` (was in original app but omitted from nuxt package.json)
|
||||
2. Fixed all TypeScript errors — 0 errors after fixes (verified via `nuxi typecheck`)
|
||||
3. Dev server (`yarn dev`) starts clean — no errors, one benign PWA workbox warning
|
||||
|
||||
## TypeScript Fixes Applied
|
||||
|
||||
### verbatimModuleSyntax: split type-only imports (TS1484/TS1485)
|
||||
|
||||
| File | Fixed |
|
||||
|------|-------|
|
||||
| `app/stores/reservation.ts` | `ComputedRef` from vue, `Timestamp` from qcalendar |
|
||||
| `app/stores/intervalTemplate.ts` | `Ref` from vue, `Models` from appwrite |
|
||||
| `app/stores/interval.ts` | `Timestamp` from qcalendar |
|
||||
| `app/components/ResourceScheduleViewerComponent.vue` | `TimestampOrNull`, `Timestamp` from qcalendar |
|
||||
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue` | `Timestamp` from qcalendar |
|
||||
| `app/components/scheduling/boat/CalendarHeaderComponent.vue` | `Timestamp` from qcalendar |
|
||||
| `app/pages/schedule/manage.vue` | `Timestamp` from qcalendar |
|
||||
| `app/pages/schedule/view.vue` | `Timestamp` from qcalendar |
|
||||
|
||||
### Actual type errors fixed
|
||||
|
||||
| File | Error | Fix |
|
||||
|------|-------|-----|
|
||||
| `app/stores/auth.ts:85` | `string \| undefined` return | `?? 'Unknown'` |
|
||||
| `app/utils/schedule.ts:8` | array index `string \| undefined` | non-null assertion `!` on `arr[i]` |
|
||||
| `app/utils/schedule.ts:23` | `split()[1]` is `string \| undefined` | non-null assertion `!` |
|
||||
| `app/utils/schedule.ts:33-34` | `arr[i-1]` possibly undefined | non-null assertion `!` |
|
||||
| `app/components/BoatReservationComponent.vue:57` | `updateInterval` handler type mismatch with `defineModel` emit | widened to `Interval \| null \| undefined` |
|
||||
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:79` | `boats.value[i].displayName` after guard | `?.displayName ?? ''` |
|
||||
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:119` | `result[key].push()` after undefined check | non-null assertion `!` |
|
||||
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:126` | `Record[key]` returns `T \| undefined` | `?? []` |
|
||||
| `app/components/scheduling/boat/BoatScheduleTableComponent.vue:175` | `boats[i].name` in template | `?.name` |
|
||||
|
||||
## Known Non-Errors (ignored)
|
||||
|
||||
- **PWA workbox WARN**: `_nuxt/builds/**/*.json` pattern matches nothing in dev mode — expected, not present in dev SW dist
|
||||
- **vue-router volar warning**: `Cannot find module '@vue/language-core'` during `nuxi typecheck` — npx version mismatch, does not affect build
|
||||
- **Deprecated Appwrite API hints (TS6387)**: `databases.listDocuments`, `createDocument`, etc. show as deprecated — these are hints, not errors; the old API still works. OPEN: migrate to new Appwrite SDK v14+ API signatures in a future session.
|
||||
|
||||
## Current State
|
||||
|
||||
- `yarn dev` — clean build, no errors
|
||||
- `nuxi typecheck` — 0 TS errors
|
||||
- All pages, stores, layouts, components in place from Phases 1–5
|
||||
- `@quasar/quasar-ui-qcalendar@4.1.2` installed
|
||||
|
||||
## What NEXT Session Should Do
|
||||
|
||||
1. **Phase 7 — QCalendar CSS / runtime verification**
|
||||
- Start `yarn dev`, open browser to `http://localhost:3000`
|
||||
- Navigate to `/login` — verify page renders
|
||||
- Test login flow (OTP or magic link)
|
||||
- Navigate to `/schedule/book` — verify QCalendarDay renders correctly
|
||||
- If calendar has no styling, add to `nuxt.config.ts`:
|
||||
```ts
|
||||
css: ['@quasar/quasar-ui-qcalendar/dist/index.css']
|
||||
```
|
||||
- Navigate to `/admin/user` and `/admin/boat` — verify admin layout renders
|
||||
|
||||
2. **Phase 8 — Generate & Deploy**
|
||||
- `yarn generate` → verify `.output/public/` built
|
||||
- Update Ansible deploy playbook if dist path changed from old `dist/` to `.output/public/`
|
||||
- Check `.gitea/workflows/build.yaml` — may need path update
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] **OPEN**: Appwrite SDK deprecated API calls — migrate to v14+ signatures? (TS6387 hints in all stores)
|
||||
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
|
||||
|
||||
## Files to Load Next Session
|
||||
|
||||
- `bab-app-nuxt/nuxt.config.ts` — if CSS import needed
|
||||
- `.gitea/workflows/build.yaml` — check output path for deploy
|
||||
- Any browser console errors from `yarn dev`
|
||||
93
docs/summaries/handoff-2026-03-19-nuxt-phase7-runtime.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Session Handoff: Nuxt Migration — Phase 7 Complete
|
||||
**Date:** 2026-03-19
|
||||
**Session Focus:** Runtime verification and fix
|
||||
**Context at Handoff:** Low — all pages working in dev, ready for Phase 8
|
||||
|
||||
## What Was Accomplished
|
||||
|
||||
**Phase 7 — Runtime Verification & Fixes**
|
||||
|
||||
All pages now load correctly in dev. Eight issues found and fixed:
|
||||
|
||||
### Fix 1: QCalendar CSS not imported
|
||||
- Added `css: ['@quasar/quasar-ui-qcalendar/dist/index.css']` to `nuxt.config.ts`
|
||||
|
||||
### Fix 2: Appwrite client initialized at module load time with wrong env var pattern
|
||||
- `app/utils/appwrite.ts` used `import.meta.env.NUXT_PUBLIC_*` — not available in Nuxt browser context
|
||||
- **Fix**: extracted `initAppwriteClient(endpoint, projectId)` function, called from plugin after `useRuntimeConfig()`
|
||||
- `app/plugins/appwrite.client.ts` now calls `useRuntimeConfig()` and guards against empty config
|
||||
|
||||
### Fix 3: `.env.local` not loaded by Nuxt 4
|
||||
- Nuxt 4 auto-loads `.env` but NOT `.env.local`
|
||||
- **Fix**: copied `.env.local` → `.env` (both are gitignored via `.env.*`)
|
||||
|
||||
### Fix 4: `AddressbarColor` plugin missing
|
||||
- Added `'AddressbarColor'` to Quasar plugins list in `nuxt.config.ts`
|
||||
- Added `?.` guard on `q.addressbarColor?.set(...)` in `app/layouts/default.vue`
|
||||
|
||||
### Fix 5: Static image assets not resolved in Vite
|
||||
- `src="~/assets/oysqn_logo.png"` in component props is not processed by Vite (unlike Webpack's `~` convention)
|
||||
- **Fix**: copied `oysqn_logo.png`, `oysqn_logo_only.png`, `oys_lighthouse.jpg` to `public/`
|
||||
- Updated 4 pages (`login.vue`, `index.vue`, `signup.vue`, `pwreset.vue`) to use `/oysqn_logo.png`
|
||||
- CSS `url('~/assets/...')` in `<style>` blocks IS handled by Vite — left unchanged
|
||||
|
||||
### Fix 6: Quasar SCSS variables (`$primary` etc.) undefined
|
||||
- Old Quasar/Webpack auto-imported variables; Vite does not
|
||||
- **Fix**: added to `nuxt.config.ts`:
|
||||
```ts
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
sass: {
|
||||
additionalData: '@use "quasar/src/css/variables.sass" as *\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
- Affects: `schedule/view.vue`, `LeftDrawer.vue`, `CalendarHeaderComponent.vue`, `BoatScheduleTableComponent.vue`
|
||||
|
||||
### Fix 7: `sass-embedded` missing
|
||||
- Added `sass-embedded` as dev dependency
|
||||
|
||||
### Fix 8: PWA service worker caching stale HTML in dev
|
||||
- Old SW was caching HTML with empty Appwrite config
|
||||
- **Fix**: set `pwa.devOptions.enabled: false` in `nuxt.config.ts`
|
||||
|
||||
## Current State
|
||||
|
||||
- `yarn dev` — all pages load correctly
|
||||
- Login, schedule, admin pages verified working
|
||||
- Appwrite auth flow functional
|
||||
- QCalendar renders with correct styling
|
||||
|
||||
## Files Changed This Session
|
||||
|
||||
- `bab-app-nuxt/nuxt.config.ts` — CSS import, Vite SASS config, AddressbarColor plugin, PWA dev disabled
|
||||
- `bab-app-nuxt/app/utils/appwrite.ts` — lazy init via `initAppwriteClient()`
|
||||
- `bab-app-nuxt/app/plugins/appwrite.client.ts` — uses `useRuntimeConfig()`, guards empty config
|
||||
- `bab-app-nuxt/app/layouts/default.vue` — optional chaining on `route?.meta?.title` and `q.addressbarColor?.set()`
|
||||
- `bab-app-nuxt/app/pages/login.vue`, `index.vue`, `signup.vue`, `pwreset.vue` — logo src path fixed
|
||||
- `bab-app-nuxt/public/` — added `oysqn_logo.png`, `oysqn_logo_only.png`, `oys_lighthouse.jpg`
|
||||
- `bab-app-nuxt/.env` — created from `.env.local` for Nuxt 4 dotenv loading
|
||||
|
||||
## What NEXT Session Should Do
|
||||
|
||||
### Phase 8 — Generate & Deploy
|
||||
|
||||
1. **Update `.gitea/workflows/build.yaml`** — current CI is for the old Quasar app:
|
||||
- Remove `yarn dlx @quasar/cli ext invoke @quasar/qcalendar` step (Quasar CLI, not needed)
|
||||
- Add `cd bab-app-nuxt` or set working-directory for install/build steps
|
||||
- Change build command to `yarn generate`
|
||||
- Change artifact path from `dist/` to `bab-app-nuxt/.output/public/`
|
||||
- Env vars: CI uses `vars.ENV_FILE` → needs to write to `bab-app-nuxt/.env`
|
||||
|
||||
2. **Test `yarn generate`** in `bab-app-nuxt/` — verify `.output/public/` is produced
|
||||
|
||||
3. **Check Ansible deploy playbook** — update dist path if needed
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] **OPEN**: Appwrite SDK deprecated API calls — migrate to v14+ signatures? (TS6387 hints in all stores)
|
||||
- [ ] **OPEN**: `task`/`taskTags`/`skillTags` collections — will they ever be created in `bab_prod`?
|
||||
- [ ] **OPEN**: Should `.env.local` be removed now that `.env` is in place, to avoid confusion?
|
||||