Initial project scaffolding

This commit is contained in:
2026-03-25 22:34:03 -04:00
commit a46c97c88a
92 changed files with 9671 additions and 0 deletions

104
app/app.vue Normal file
View File

@@ -0,0 +1,104 @@
<template>
<IonApp>
<IonMenu content-id="main-content" menu-id="main-menu">
<IonHeader>
<IonToolbar color="primary">
<IonTitle>OYS Borrow a Boat</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList lines="none">
<!-- All authenticated users -->
<IonItem button router-link="/" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="homeOutline" />
<IonLabel>Home</IonLabel>
</IonItem>
<IonItem button router-link="/schedule" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="calendarOutline" />
<IonLabel>Schedule</IonLabel>
</IonItem>
<IonItem button router-link="/boat" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="boatOutline" />
<IonLabel>Boats</IonLabel>
</IonItem>
<IonItem button router-link="/reference" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="bookOutline" />
<IonLabel>Reference</IonLabel>
</IonItem>
<IonItem button router-link="/profile" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="personOutline" />
<IonLabel>Profile</IonLabel>
</IonItem>
<!-- Boatswain + Admin: schedule management -->
<template v-if="authStore.isBoatswain">
<IonItemDivider>
<IonLabel>Management</IonLabel>
</IonItemDivider>
<IonItem button router-link="/schedule/manage" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="calendarNumberOutline" />
<IonLabel>Manage Schedule</IonLabel>
</IonItem>
</template>
<!-- Admin only -->
<template v-if="authStore.isAdmin">
<IonItem button router-link="/admin/user" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="peopleOutline" />
<IonLabel>Users</IonLabel>
</IonItem>
<IonItem button router-link="/admin/boat" router-direction="root" @click="closeMenu">
<IonIcon slot="start" :icon="constructOutline" />
<IonLabel>Manage Boats</IonLabel>
</IonItem>
</template>
<!-- Sign out -->
<IonItemDivider />
<IonItem button @click="signOut">
<IonIcon slot="start" :icon="logOutOutline" />
<IonLabel>Sign Out</IonLabel>
<IonNote slot="end">{{ authStore.displayName }}</IonNote>
</IonItem>
</IonList>
</IonContent>
</IonMenu>
<IonRouterOutlet id="main-content" />
</IonApp>
</template>
<script setup lang="ts">
import {
IonApp, IonMenu, IonHeader, IonToolbar, IonTitle,
IonContent, IonList, IonItem, IonItemDivider, IonIcon, IonLabel, IonNote,
IonRouterOutlet, menuController,
} from '@ionic/vue'
import {
homeOutline, calendarOutline, boatOutline, personOutline,
bookOutline, calendarNumberOutline, peopleOutline, constructOutline, logOutOutline,
} from 'ionicons/icons'
import { useAuthStore } from '~/stores/auth'
const authStore = useAuthStore()
async function closeMenu() {
await menuController.close('main-menu')
}
async function signOut() {
await closeMenu()
await authStore.signOut()
}
// Fetch member profile on app load (populates role for menu visibility)
onMounted(() => {
if (authStore.user) authStore.fetchMember()
})
// Re-fetch when user changes (e.g., after login)
watch(() => authStore.user, (u) => {
if (u) authStore.fetchMember()
else authStore.member = null
})
</script>

View File

@@ -0,0 +1,15 @@
import { toastController } from '@ionic/vue'
export function useIonToast() {
async function showToast(message: string, color: 'success' | 'danger' | 'warning' = 'success') {
const toast = await toastController.create({
message,
duration: 2000,
color,
position: 'bottom',
})
await toast.present()
}
return { showToast }
}

10
app/middleware/auth.ts Normal file
View File

@@ -0,0 +1,10 @@
export default defineNuxtRouteMiddleware((to) => {
const user = useSupabaseUser()
const publicRoutes = ['/login', '/signup', '/auth/callback']
if (publicRoutes.includes(to.path)) return
if (!user.value) {
return navigateTo('/login')
}
})

View File

@@ -0,0 +1,13 @@
<template>
<IonPage>
<IonContent class="ion-padding ion-text-center">
<p>Signing you in...</p>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import { IonPage, IonContent } from '@ionic/vue'
definePageMeta({ layout: false })
</script>

22
app/pages/index.vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonButtons slot="start">
<IonMenuButton />
</IonButtons>
<IonTitle>Home</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<h2>Welcome to OYS Borrow a Boat</h2>
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import {
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
IonButtons, IonMenuButton,
} from '@ionic/vue'
</script>

18
app/pages/login.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<IonPage>
<IonHeader>
<IonToolbar color="primary">
<IonTitle>Sign In</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent class="ion-padding">
<!-- TODO: Auth form -->
</IonContent>
</IonPage>
</template>
<script setup lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue'
definePageMeta({ layout: false })
</script>

View File

@@ -0,0 +1,7 @@
import { IonicVue } from '@ionic/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(IonicVue, {
mode: 'md', // Use Material Design style on all platforms for consistency
})
})

115
app/stores/auth.ts Normal file
View File

@@ -0,0 +1,115 @@
import { defineStore } from 'pinia'
import type { Database, MemberRole } from '~/types/supabase'
export type Member = Database['public']['Tables']['members']['Row']
export const useAuthStore = defineStore('auth', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = useSupabaseClient() as any
const user = useSupabaseUser()
const member = ref<Member | null>(null)
const userNames = ref<Record<string, string>>({})
const role = computed<MemberRole | null>(() => member.value?.role ?? null)
function hasRequiredRole(requiredRoles: MemberRole[]): boolean {
if (!role.value) return false
return requiredRoles.includes(role.value)
}
const isAdmin = computed(() => hasRequiredRole(['admin']))
const isBoatswain = computed(() => hasRequiredRole(['admin', 'boatswain']))
const displayName = computed(() => {
if (!member.value) return user.value?.email ?? ''
const { first_name, last_name } = member.value
if (first_name || last_name) return `${first_name} ${last_name}`.trim()
return user.value?.email ?? ''
})
async function fetchMember() {
if (!user.value) { member.value = null; return }
const { data } = await supabase
.from('members')
.select('*')
.eq('user_id', user.value.id)
.single()
member.value = data
}
// Returns cached name or triggers async fetch; updates reactively when resolved.
function getUserNameById(id: string | null | undefined): string {
if (!id) return 'No User'
if (!userNames.value[id]) {
userNames.value[id] = 'Loading...'
supabase
.from('members')
.select('first_name, last_name')
.eq('user_id', id)
.single()
.then(({ data }: { data: Pick<Member, 'first_name' | 'last_name'> | null }) => {
if (data) {
userNames.value[id] = `${data.first_name} ${data.last_name}`.trim() || 'Unknown'
} else {
userNames.value[id] = 'Unknown'
}
})
}
return userNames.value[id]!
}
async function signOut() {
await supabase.auth.signOut()
member.value = null
await navigateTo('/login')
}
async function sendMagicLink(email: string) {
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: window.location.origin + '/auth/callback' },
})
if (error) throw error
}
async function sendOtp(email: string) {
const { error } = await supabase.auth.signInWithOtp({ email })
if (error) throw error
}
async function verifyOtp(email: string, token: string) {
const { error } = await supabase.auth.verifyOtp({ email, token, type: 'email' })
if (error) throw error
await fetchMember()
}
async function updateName(firstName: string, lastName: string) {
if (!user.value) return
const { error } = await supabase
.from('members')
.update({ first_name: firstName, last_name: lastName })
.eq('user_id', user.value.id)
if (error) throw error
if (member.value) {
member.value.first_name = firstName
member.value.last_name = lastName
}
}
return {
user,
member,
role,
isAdmin,
isBoatswain,
displayName,
hasRequiredRole,
getUserNameById,
fetchMember,
sendMagicLink,
sendOtp,
verifyOtp,
updateName,
signOut,
}
})

23
app/stores/boat.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
// TODO: Replace with generated Supabase types after `npx supabase gen types typescript`
interface Boat {
id: string
name: string
description: string | null
active: boolean
created_at: string
}
export const useBoatStore = defineStore('boat', () => {
const supabase = useSupabaseClient()
const boats = ref<Map<string, Boat>>(new Map())
async function fetchBoats() {
const { data, error } = await supabase.from('boats').select('*').order('name')
if (error) throw error
boats.value = new Map((data as Boat[]).map((b) => [b.id, b]))
}
return { boats, fetchBoats }
})

360
app/types/supabase.ts Normal file
View File

@@ -0,0 +1,360 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
// Domain types
export type MemberRole = 'member' | 'skipper' | 'admin' | 'boatswain' | 'volunteer' | 'instructor'
export type ReservationStatus = 'pending' | 'tentative' | 'confirmed'
export interface Defect {
type: string
severity: string
description: string
detail?: string
}
// time_tuples shape: [[startHHMM, endHHMM], ...] e.g. [["08:00","12:00"]]
export type TimeTuple = [string, string]
export type Database = {
__InternalSupabase: {
PostgrestVersion: "14.4"
}
public: {
Tables: {
boats: {
Row: {
id: string
name: string
display_name: string | null
class: string | null
year: number | null
img_src: string | null
icon_src: string | null
booking_available: boolean
required_certs: string[]
max_passengers: number
defects: Defect[]
created_at: string
}
Insert: {
id?: string
name: string
display_name?: string | null
class?: string | null
year?: number | null
img_src?: string | null
icon_src?: string | null
booking_available?: boolean
required_certs?: string[]
max_passengers?: number
defects?: Defect[]
created_at?: string
}
Update: {
id?: string
name?: string
display_name?: string | null
class?: string | null
year?: number | null
img_src?: string | null
icon_src?: string | null
booking_available?: boolean
required_certs?: string[]
max_passengers?: number
defects?: Defect[]
created_at?: string
}
}
members: {
Row: {
id: string
user_id: string
first_name: string
last_name: string
email: string
slack_id: string | null
certifications: string[]
role: MemberRole
created_at: string
}
Insert: {
id?: string
user_id: string
first_name?: string
last_name?: string
email: string
slack_id?: string | null
certifications?: string[]
role?: MemberRole
created_at?: string
}
Update: {
id?: string
user_id?: string
first_name?: string
last_name?: string
email?: string
slack_id?: string | null
certifications?: string[]
role?: MemberRole
created_at?: string
}
}
interval_templates: {
Row: {
id: string
name: string
time_tuples: TimeTuple[]
created_at: string
}
Insert: {
id?: string
name: string
time_tuples?: TimeTuple[]
created_at?: string
}
Update: {
id?: string
name?: string
time_tuples?: TimeTuple[]
created_at?: string
}
}
intervals: {
Row: {
id: string
boat_id: string
start_time: string
end_time: string
user_id: string | null
created_at: string
}
Insert: {
id?: string
boat_id: string
start_time: string
end_time: string
user_id?: string | null
created_at?: string
}
Update: {
id?: string
boat_id?: string
start_time?: string
end_time?: string
user_id?: string | null
created_at?: string
}
}
reservations: {
Row: {
id: string
boat_id: string
user_id: string
start_time: string
end_time: string
status: ReservationStatus
reason: string
comment: string
member_ids: string[]
guest_ids: string[]
created_at: string
}
Insert: {
id?: string
boat_id: string
user_id: string
start_time: string
end_time: string
status?: ReservationStatus
reason?: string
comment?: string
member_ids?: string[]
guest_ids?: string[]
created_at?: string
}
Update: {
id?: string
boat_id?: string
user_id?: string
start_time?: string
end_time?: string
status?: ReservationStatus
reason?: string
comment?: string
member_ids?: string[]
guest_ids?: string[]
created_at?: string
}
}
reference_docs: {
Row: {
id: string
title: string
category: string
tags: string[]
subtitle: string | null
content: string
created_at: string
}
Insert: {
id?: string
title: string
category: string
tags?: string[]
subtitle?: string | null
content: string
created_at?: string
}
Update: {
id?: string
title?: string
category?: string
tags?: string[]
subtitle?: string | null
content?: string
created_at?: string
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
member_role: MemberRole
reservation_status: ReservationStatus
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const