Seating Chart Visualizer with Vue 3
Build an interactive seat map with image overlays, CSV parsing, and shareable links.
Introduction
30 Second Summary
Walking into a packed classroom and having no idea where to sit is universally awkward. Imagine typing your name and instantly seeing your assigned spot light up on the map.
In this project, you will build a "Find Your Seat" web app using Vue 3 and Supabase. An organizer uploads a classroom image and student CSV to generate a shareable interactive seat map.
What You'll Build
A full-stack seating chart visualizer that combines file parsing, image overlay positioning, and session-based architecture with anonymous authentication.
By the end of this project, you'll have:
- A Vue 3 single-page application with organizer and guest views connected by shareable session URLs.
- A Supabase backend with anonymous authentication, database tables, file storage, and RLS policies.
- An interactive seat map powered by CSV parsing, percentage-based image overlays, and a bounding box calibration tool.
- A search-as-you-type seat highlighting feature that filters matches in real time (secret mission).
Are there any prerequisites?
This project assumes familiarity with Vue.js basics and Supabase fundamentals like database queries and storage uploads. You should also have VS Code installed on your machine.
Install Node.js and Scaffold the Vue Project
Your seating chart app is a Vue 3 single-page application powered by Vite, a modern build tool.
Before you can start building, you need Node.js to run JavaScript tooling and a Vue project scaffold to structure your code. In this step, you will install Node.js, create a fresh Vue 3 project with Vue Router, and add the packages your app needs.
In this step, get ready to:
- Install Node.js 24 LTS.
- Scaffold a Vue 3 project with Vue Router enabled.
- Install Supabase and PapaParse dependencies.
Download and install Node.js 24 LTS
Node.js is the JavaScript runtime that powers the development server and package manager (npm) behind your Vue project.
- Open the Node.js download page in your browser.
- Download the 24 LTS installer for your operating system.
- Run the installer and accept the default settings.
After the installer finishes, open a terminal to verify it worked.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check your Node.js version by running this command:
node --version
I see a version number
You should see something like v24.x.x. Node.js is ready.
- Confirm npm is also available by running:
npm --version
You should see a version number like 10.x.x. Both tools are installed and ready to go.
Command not found
This means Node.js is not installed yet. Follow these steps to install it.
- Go to nodejs.org.
- Click the 24 LTS download button.
- Run the downloaded installer and accept all default settings.
- Close your terminal completely and reopen it.
- Run node --version again. You should now see a version number.
Still seeing command not found?
Make sure you closed and reopened your terminal after installing. The terminal needs a fresh session to pick up the new Node.js path.
Help me troubleshoot my Node.js installation
Scaffold a new Vue 3 project
The official create-vue tool scaffolds a Vue 3 project with Vite as the build tool. It asks a series of questions so you can opt into features like Vue Router during setup.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Vue project by running this command:
npm create vue@latest
The tool launches an interactive wizard. Answer each prompt as follows:
- Type seating-chart for the Project name.
- Select No for Add TypeScript.
- Select No for Add JSX Support.
- Select Yes for Add Vue Router.
- Select No for Add Pinia.
- Select No for Add Vitest.
- Select No for Add End-to-End Testing.
- Select No for Add ESLint.
- Select No for Add Vue DevTools.
Why Vue Router?
Vue Router lets your app have multiple pages (views) that share a single URL bar. Your seating chart needs three views: one for the organizer to create a session, one for calibration, and one for guests to find their seats. Vue Router maps each URL path to the correct view.
- Move into the new project folder and install the base dependencies by running these commands:
cd seating-chart
npm install
What does npm install do?
This command reads the package.json file that create-vue generated and downloads all the packages your project depends on (Vue, Vue Router, Vite) into a node_modules folder.
Seeing npm errors during install?
Make sure you ran cd seating-chart before running npm install. The command needs to be run from inside the project folder where package.json lives.
Help me fix npm install errors in my Vue project
The scaffold creates sample views, components, and styles that you do not need. Next, you will replace three files with a clean starting point for your project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the seating-chart folder on your Desktop, then click Open.
- In the Explorer sidebar, click src to expand it, then click main.js to open it.
- Select all the existing content with Cmd+A (macOS) or Ctrl+A (Windows), then replace it with the following code:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
// Create the Vue app, register the router, and mount it
const app = createApp(App)
app.use(router)
app.mount('#app')
What does this code do?
- createApp(App) creates a Vue application instance from your root component.
- app.use(router) registers Vue Router so your app can handle multiple URL paths.
- import './assets/main.css' loads a global stylesheet that applies to the entire app.
- Save src/main.js with Cmd+S (macOS) or Ctrl+S (Windows).
- Open src/App.vue from the Explorer sidebar.
- Select all the existing content and replace it with the following code:
<script setup>
</script>
<template>
<div id="app">
<!-- Renders whichever view matches the current URL -->
<RouterView />
</div>
</template>
<style>
/* Center the app content and set a max width */
#app {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
</style>
What does this code do?
- <RouterView /> is a Vue Router component that renders whichever view matches the current URL. When the user visits /session/abc123, it shows the guest view. When they visit /, it shows the session creation form.
- The #app styles center the content with a maximum width of 1200px so the layout stays readable on large screens.
- Save src/App.vue.
- In the Explorer sidebar, expand the src/assets folder and open main.css.
- Select all the existing content and replace it with the following code:
/* Reset default browser spacing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Set a clean base font and background for the whole page */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f9fafb;
color: #111827;
line-height: 1.6;
}
What does this code do?
- The * selector resets margins and padding on every element so you start with a predictable layout.
- box-sizing: border-box ensures padding is included in element widths, preventing unexpected overflow.
- The body styles set a clean font stack, a light gray background, and comfortable line height for the entire page.
- Save src/assets/main.css.
Not sure if you replaced the files correctly?
Make sure you selected all the old content with Cmd+A / Ctrl+A before pasting. If any of the original scaffold code remains, the file will not match what you need.
Help me verify my files are set up correctly
Awesome, I've got everything!
Make sure all three files are saved before moving on.
I'd like to double check the full code
Here is what each file should look like at this point.
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(router)
app.mount('#app')
<script setup>
</script>
<template>
<div id="app">
<RouterView />
</div>
</template>
<style>
#app {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
</style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f9fafb;
color: #111827;
line-height: 1.6;
}
Install project dependencies and start the dev server
Two additional packages power the app's backend connection and file parsing.
- Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Install Supabase JS and PapaParse by running this command:
npm install @supabase/supabase-js@2 papaparse@5
What are these packages?
- @supabase/supabase-js@2 is the JavaScript client library for Supabase. It handles database queries, file storage uploads, and authentication from the browser.
- papaparse@5 is a CSV parsing library. It reads the organizer's uploaded CSV file and converts it into JavaScript objects your app can work with.
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
You should see a page with some Vue sample content. The scaffold's default router still points to the sample views, which is expected. You will replace these routes in a later step.
Page not loading at localhost:5173?
- Check your terminal output. Vite prints the local URL when the server starts. If it shows a different port (like 5174), use that URL instead.
- Make sure npm run dev is still running in your terminal. If you see an error, try stopping it with Ctrl+C and running it again.
Help me get my Vite dev server running
Your project is scaffolded and the dev server is running. Next up, you will set up the Supabase backend with database tables, storage, and authentication so your app has somewhere to save session data.
Set Up Supabase Backend
Your Vue project is scaffolded and the dev server is running. Now you need a backend to store session data, seat assignments, and uploaded images.
Supabase is an open-source backend platform built on PostgreSQL. In this step you will create a Supabase project, set up database tables with security policies, and create a storage bucket for seating layout images.
In this step, get ready to:
- Create a Supabase project and connect it to your Vue app.
- Build database tables for sessions and seat assignments.
- Set up a storage bucket for seating layout images.
Create a Supabase project and connect it to your app
Before your app can store anything, it needs a backend. You will create a free Supabase project, grab its connection credentials, and wire them into your Vue app.
- Open your browser and go to supabase.com.
- Click Start your project and sign up for a free account (or sign in if you already have one).
- Click New project.
- Select your default organization.
- Enter seating-chart as the project name.
- Set a secure database password and save it somewhere safe.
- Choose a region close to you.
- Click Create new project.
Supabase takes about a minute to provision your project. Wait until you see the project dashboard before continuing.
What is Supabase?
Supabase gives you a hosted PostgreSQL database, file storage, and authentication out of the box. You interact with it through a JavaScript client library in your Vue app. The free tier includes 500MB of database storage, 1GB of file storage, and 50K monthly active users.
Now you need two credentials from your project: the Project URL and the anon (public) API key.
- In the Supabase dashboard, click the Connect button in the top navigation bar.
- In the Connect dialog, find and copy the Project URL (it looks like https://abcdefg.supabase.co).
- Copy the API Key shown in the dialog. This is the publishable (anon) key for client-side operations.
Pro tip: finding keys later
You can always find your keys by going to Project Settings (gear icon in the left sidebar), then API Keys. The anon key under Legacy API Keys is the same publishable key.
Now create the Supabase client file in your Vue project.
- In VS Code, right-click the src folder in the Explorer sidebar and select New Folder.
- Name the folder lib.
- Right-click the new lib folder and select New File.
- Name the file supabase.js.
- Paste the following code into src/lib/supabase.js, replacing the placeholder strings with the credentials you just copied:
import { createClient } from '@supabase/supabase-js'
// Replace these with your Supabase project credentials
const supabaseUrl = 'your-supabase-project-url'
const supabaseKey = 'your-supabase-anon-key'
export const supabase = createClient(supabaseUrl, supabaseKey)
What does this code do?
- createClient initializes a Supabase client that your Vue components will use to query the database, upload files, and handle authentication.
- The supabaseUrl points to your specific project on Supabase's servers.
- The supabaseKey is the publishable anon key. It is safe to include in client-side code because RLS policies (which you will set up next) control what data each user can access.
- Save src/lib/supabase.js.
Seeing a red underline on the import?
Make sure you installed @supabase/supabase-js in Step 1. If the import is unresolved, run npm install @supabase/supabase-js@2 from the seating-chart folder.
Help me fix an import error for @supabase/supabase-js in my Vue project.
Awesome, I've got everything!
Great. Double check that you saved src/lib/supabase.js and that your URL and key are filled in.
I'd like to double check the full code
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = 'your-supabase-project-url'
const supabaseKey = 'your-supabase-anon-key'
export const supabase = createClient(supabaseUrl, supabaseKey)
One last thing before you move to the database. You need to enable anonymous sign-ins so organizers can upload files without creating a traditional account.
- In the Supabase dashboard left sidebar, click Authentication.
- Click Sign In / Up in the Authentication sidebar.
- Scroll down to the Anonymous Sign-Ins section.
- Toggle Allow anonymous sign-ins to on.
- Click Save.
Why anonymous sign-ins?
Supabase Storage requires an authenticated user to upload files. Anonymous sign-in creates a temporary user session without asking for an email or password. The organizer gets upload permissions instantly, and guests reading the data do not need to sign in at all.
Create the database tables
Your app needs two tables: one for sessions (each seating chart an organizer creates) and one for seats (individual student assignments). You will create both using the Supabase SQL Editor.
- In the Supabase dashboard left sidebar, click SQL Editor.
- Click New query in the top left.
- Create the sessions table by pasting this SQL and clicking Run:
-- Store each seating chart session
create table sessions (
id uuid primary key default gen_random_uuid(),
admin_token text not null, -- secret token for organizer access
image_url text, -- public URL of the uploaded layout image
calibration jsonb, -- bounding box coordinates for seat placement
created_at timestamptz default now()
);
What does this table store?
- Each row is one seating chart session. The id is an auto-generated UUID that becomes part of the shareable URL.
- admin_token is a secret string the organizer uses to prove they own the session.
- calibration stores the bounding box coordinates as JSON. You will use this later to position seats on the image.
- You should see a Success. No rows returned message below the editor.
Seeing an error instead of Success?
Check that you copied the full SQL block including the closing );. A missing parenthesis or semicolon is the most common cause.
Help me fix a SQL error when creating my sessions table in Supabase.
Now create the seats table in the same SQL Editor.
- Clear the editor, paste the following SQL, and click Run:
-- Store individual seat assignments linked to a session
create table seats (
id uuid primary key default gen_random_uuid(),
session_id uuid references sessions(id) on delete cascade, -- link to parent session
student_name text not null,
seat_code text not null, -- grid code like "A-1", "B-3"
left_handed boolean default false,
position_x numeric, -- calibrated X position (percentage)
position_y numeric -- calibrated Y position (percentage)
);
What does this table store?
- Each row is one student's seat assignment. The session_id foreign key links the seat to a specific seating chart session.
- on delete cascade means deleting a session automatically deletes all of its seats.
- seat_code holds values like A-1 or B-3. Your app will parse these codes to figure out the grid layout.
- Confirm you see Success. No rows returned again.
Both tables exist, but they are wide open by default. You need to enable Row Level Security and add policies that control who can read and write data.
- Clear the editor, paste the following SQL, and click Run:
-- Enable Row Level Security on both tables
alter table sessions enable row level security;
alter table seats enable row level security;
-- Anyone (guests and organizers) can read sessions
create policy "Sessions are readable by everyone"
on sessions for select
to anon, authenticated
using (true);
-- Only authenticated users (organizers) can create sessions
create policy "Authenticated users can insert sessions"
on sessions for insert
to authenticated
with check (true);
-- Only authenticated users can update sessions (e.g. save calibration)
create policy "Authenticated users can update sessions"
on sessions for update
to authenticated
using (true);
-- Anyone can read seat assignments
create policy "Seats are readable by everyone"
on seats for select
to anon, authenticated
using (true);
-- Only authenticated users can add seats
create policy "Authenticated users can insert seats"
on seats for insert
to authenticated
with check (true);
-- Only authenticated users can update seats
create policy "Authenticated users can update seats"
on seats for update
to authenticated
using (true);
What is RLS and what do these policies do?
Row Level Security (RLS) locks down every table by default. Without policies, nobody can read or write data through the Supabase client.
- The select policies let both anon (unauthenticated guests) and authenticated (signed-in organizers) read data. This is what makes the guest link work without requiring login.
- The insert and update policies restrict writes to authenticated users only. The organizer gets this role through anonymous sign-in.
- Confirm you see Success. No rows returned.
Seeing a "policy already exists" error?
This means the policies were already created from a previous run. You can safely ignore this error or drop the existing policies first with drop policy "policy name" on table_name; before re-running.
Help me fix a duplicate RLS policy error in Supabase.
Verify both tables are visible in the dashboard.
- In the left sidebar, click Table Editor.
- You should see both sessions and seats listed in the left panel. Each table shows a lock icon indicating RLS is enabled.
Create the storage bucket
Organizers upload a seating layout image for each session. You need a storage bucket in Supabase to hold these files.
- In the Supabase dashboard left sidebar, click Storage.
- Click New bucket.
- Enter seating-images as the bucket name.
- Toggle Public bucket to on. This lets guests view images via a public URL without authentication.
- Under Allowed MIME types, enter image/*.
- Set the Max file size to 5 MB.
- Click Create bucket.
You should see seating-images listed in the Storage panel. The bucket is public for reads, but uploads still require authentication. You need to add RLS policies on the storage.objects table to allow this.
- Go back to the SQL Editor in the left sidebar.
- Click New query, paste the following SQL, and click Run:
-- Allow authenticated users to upload images to the seating-images bucket
create policy "Authenticated users can upload seating images"
on storage.objects for insert
to authenticated
with check (bucket_id = 'seating-images');
-- Allow everyone to view uploaded seating images
create policy "Anyone can view seating images"
on storage.objects for select
to anon, authenticated
using (bucket_id = 'seating-images');
What do these storage policies do?
- The insert policy lets authenticated users (organizers who signed in anonymously) upload files to the seating-images bucket.
- The select policy lets everyone (including unauthenticated guests) read files from the bucket.
- Without these policies, Supabase denies all storage operations by default.
- Confirm you see Success. No rows returned.
Getting a permission denied error?
Make sure the bucket name in your SQL exactly matches seating-images (with a hyphen, not an underscore). The bucket_id value must match the name you entered when creating the bucket.
Help me fix a storage policy error in Supabase.
Your backend is fully configured. Next up, you will build the session creation flow where the organizer uploads files and the app parses the CSV into seat assignments.
Build the Session Creation and File Upload Flow
Your Supabase backend has tables, storage, and auth policies ready to go. Now it's time to build the frontend that talks to it.
In this step, you'll create the session creation page where an organizer uploads a seating layout image and a CSV of student assignments. The app processes both files, stores them in Supabase, and redirects to the admin view.
In this step, get ready to:
- Create the session creation form with file upload inputs.
- Build the submission handler that uploads files and saves data to Supabase.
- Set up Vue Router with routes for the organizer and guest views.
Create the session creation form
The CreateSession component is the app's landing page. It shows a form where the organizer picks an image file and a CSV file, then kicks off the session creation process.
- Create a new file at src/views/CreateSession.vue and add this <script setup> block:
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import Papa from 'papaparse'
import { supabase } from '../lib/supabase.js'
// Vue Router instance for navigating after session creation
const router = useRouter()
// Reactive state for file inputs and UI feedback
const imageFile = ref(null)
const csvFile = ref(null)
const loading = ref(false)
const error = ref('')
// Generate a unique token the organizer uses to access admin features
function generateToken() {
return crypto.randomUUID()
}
// Placeholder - we'll build this in the next substep
async function handleSubmit() {}
// Store the selected files in reactive refs when inputs change
function onImageChange(event) {
imageFile.value = event.target.files[0] || null
}
function onCsvChange(event) {
csvFile.value = event.target.files[0] || null
}
</script>
What does this code do?
- The four imports pull in Vue's reactivity (ref), the router for navigation, PapaParse for CSV parsing, and your Supabase client.
- imageFile and csvFile hold the files the organizer selects from the form.
- loading and error drive the UI state so the button disables during upload and errors display inline.
- generateToken() creates a unique admin token using the browser's built-in crypto.randomUUID() method.
- Below the closing </script> tag, add the template:
<template>
<div class="create-session">
<h1>Create a Seating Chart</h1>
<p>Upload your seating layout image and a CSV file with student assignments.</p>
<form @submit.prevent="handleSubmit" class="form">
<div class="field">
<label for="image-input">Seating Layout Image</label>
<input
id="image-input"
type="file"
accept="image/*"
@change="onImageChange"
/>
</div>
<div class="field">
<label for="csv-input">Student CSV (columns: Student, Seat, Left handed?)</label>
<input
id="csv-input"
type="file"
accept=".csv"
@change="onCsvChange"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? 'Creating...' : 'Create Session' }}
</button>
</form>
</div>
</template>
What does this template do?
- @submit.prevent="handleSubmit" prevents the default form submission and calls your handler function instead.
- The first file input accepts any image type (accept="image/*"). The second accepts only .csv files.
- The error paragraph only renders when error has a value, thanks to v-if.
- The button disables itself while loading is true and swaps its label to "Creating..." so the organizer knows the upload is in progress.
- Below the closing </template> tag, add the scoped styles:
<style scoped>
.create-session {
max-width: 600px;
margin: 40px auto;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 30px;
text-align: left;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-weight: 600;
font-size: 0.9rem;
}
button {
padding: 12px 24px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #dc2626;
font-size: 0.9rem;
}
</style>
What do these styles do?
The scoped attribute limits these styles to this component only.
The form is centered at 600px max width with a column layout. The button uses an indigo background that fades to 60% opacity when disabled.
- Save CreateSession.vue.
You won't see this component in the browser yet. The router still points to the scaffold's default views. You'll update it in the last substep.
Build the form submission handler
The stub handleSubmit function does nothing right now.
The real version needs to do five things in sequence: sign in anonymously, upload the image, create a session row, parse the CSV, and insert the seat records.
- In CreateSession.vue, find this placeholder inside the <script setup> block:
// Placeholder - we'll build this in the next substep
async function handleSubmit() {}
- Replace the entire stub (both lines) with the full implementation:
async function handleSubmit() {
// Validate that both files are selected
if (!imageFile.value || !csvFile.value) {
error.value = 'Please select both an image and a CSV file.'
return
}
loading.value = true
error.value = ''
try {
// Sign in anonymously so Supabase allows the upload
const { data: authData, error: authError } = await supabase.auth.signInAnonymously()
if (authError) throw authError
// Generate a unique admin token for this session
const adminToken = generateToken()
// Prefix a UUID to avoid filename collisions in storage
const imageFileName = `${crypto.randomUUID()}-${imageFile.value.name}`
// Upload the seating layout image to the storage bucket
const { data: uploadData, error: uploadError } = await supabase.storage
.from('seating-images')
.upload(imageFileName, imageFile.value, { contentType: imageFile.value.type })
if (uploadError) throw uploadError
// Get the publicly accessible URL for the uploaded image
const { data: publicUrlData } = supabase.storage
.from('seating-images')
.getPublicUrl(imageFileName)
const imageUrl = publicUrlData.publicUrl
// Create a new session row in the database
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.insert({ admin_token: adminToken, image_url: imageUrl })
.select('id')
.single()
if (sessionError) throw sessionError
const sessionId = sessionData.id
// Read and parse the CSV file with PapaParse
const csvText = await csvFile.value.text()
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
// Map each CSV row to a seat record for the database
const seats = parsed.data.map((row) => ({
session_id: sessionId,
student_name: row['Student'] || row['student'] || '',
seat_code: row['Seat'] || row['seat'] || '',
left_handed: (row['Left handed?'] || row['left handed?'] || '').toUpperCase() === 'YES',
}))
// Bulk-insert all seat records
const { error: seatsError } = await supabase.from('seats').insert(seats)
if (seatsError) throw seatsError
// Redirect the organizer to the admin calibration view
router.push({
name: 'admin',
params: { id: sessionId },
query: { token: adminToken },
})
} catch (err) {
error.value = err.message || 'Something went wrong.'
} finally {
loading.value = false
}
}
What does this handler do?
This function runs five operations in sequence, wrapped in a try/catch so any failure shows a user-friendly error message.
- signInAnonymously() creates a temporary Supabase session. This gives the organizer upload permissions without requiring an account.
- The image is uploaded to the seating-images bucket with a UUID-prefixed filename to prevent collisions.
- getPublicUrl() returns a URL anyone can access, which is stored in the sessions table.
- Papa.parse() reads the CSV with header: true, so each row becomes an object keyed by column name (e.g. row['Student']).
- The column lookups check both capitalized and lowercase versions (row['Student'] || row['student']) to handle slight CSV formatting differences.
- After inserting all seats, router.push() redirects to the admin view with the session ID and admin token in the URL.
Why sign in anonymously instead of requiring an account?
Supabase Storage requires an authenticated user for uploads. Anonymous auth gives the organizer a real session token without any sign-up friction.
Guests viewing the seating chart don't need auth at all. Your RLS policies already allow the anon role to read sessions and seats.
- Save CreateSession.vue.
The handler is ready, but you still need to update the router before you can test the full flow.
Set up Vue Router
Vue Router maps URL paths to components. The scaffold's router config still points to the sample views from create-vue. You need to replace it with routes for your three views.
- Open src/router/index.js and replace its entire contents with:
import { createRouter, createWebHistory } from 'vue-router'
import CreateSession from '../views/CreateSession.vue'
import GuestView from '../views/GuestView.vue'
import AdminView from '../views/AdminView.vue'
// Define the three main routes for the app
const routes = [
{ path: '/', name: 'home', component: CreateSession },
{ path: '/session/:id', name: 'guest', component: GuestView },
{ path: '/session/:id/admin', name: 'admin', component: AdminView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
What do these routes do?
- / loads CreateSession. This is the landing page where the organizer uploads files.
- /session/:id loads GuestView. The :id is a dynamic segment that captures the session UUID from the URL.
- /session/:id/admin loads AdminView. The admin token is passed as a query parameter, not part of the route path.
Why is the admin token in the query parameter?
Putting the token in the URL (like ?token=abc123) means the organizer can bookmark or share the admin link directly.
This is the same pattern tools like when2meet use for disposable sessions. No account needed, just a unique URL.
- Save src/router/index.js.
- Open your browser to http://localhost:5173.
You should see the "Create a Seating Chart" heading with two file input fields and a purple "Create Session" button.
Don't see the form?
- Check your terminal for errors. The router imports GuestView and AdminView, which don't exist yet. Vite may show warnings but should still load the home route.
- Make sure the dev server is still running. If it stopped, run npm run dev again from inside your seating-chart folder.
- Confirm that src/views/CreateSession.vue exists and is saved. The router expects this exact path.
Help me debug why my CreateSession form isn't showing up at localhost:5173.
Seeing console warnings about GuestView or AdminView?
This is expected. The router imports components you haven't created yet. Vite may log warnings, but the app still loads because the / route only needs CreateSession. You'll create the other two views in the next steps.
Awesome, I've got everything!
Make sure both CreateSession.vue and router/index.js are saved.
I'd like to double check the full code
Here is the complete CreateSession.vue file:
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import Papa from 'papaparse'
import { supabase } from '../lib/supabase.js'
const router = useRouter()
const imageFile = ref(null)
const csvFile = ref(null)
const loading = ref(false)
const error = ref('')
function generateToken() {
return crypto.randomUUID()
}
async function handleSubmit() {
if (!imageFile.value || !csvFile.value) {
error.value = 'Please select both an image and a CSV file.'
return
}
loading.value = true
error.value = ''
try {
const { data: authData, error: authError } = await supabase.auth.signInAnonymously()
if (authError) throw authError
const adminToken = generateToken()
const imageFileName = `${crypto.randomUUID()}-${imageFile.value.name}`
const { data: uploadData, error: uploadError } = await supabase.storage
.from('seating-images')
.upload(imageFileName, imageFile.value, { contentType: imageFile.value.type })
if (uploadError) throw uploadError
const { data: publicUrlData } = supabase.storage
.from('seating-images')
.getPublicUrl(imageFileName)
const imageUrl = publicUrlData.publicUrl
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.insert({ admin_token: adminToken, image_url: imageUrl })
.select('id')
.single()
if (sessionError) throw sessionError
const sessionId = sessionData.id
const csvText = await csvFile.value.text()
const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true })
const seats = parsed.data.map((row) => ({
session_id: sessionId,
student_name: row['Student'] || row['student'] || '',
seat_code: row['Seat'] || row['seat'] || '',
left_handed: (row['Left handed?'] || row['left handed?'] || '').toUpperCase() === 'YES',
}))
const { error: seatsError } = await supabase.from('seats').insert(seats)
if (seatsError) throw seatsError
router.push({
name: 'admin',
params: { id: sessionId },
query: { token: adminToken },
})
} catch (err) {
error.value = err.message || 'Something went wrong.'
} finally {
loading.value = false
}
}
function onImageChange(event) {
imageFile.value = event.target.files[0] || null
}
function onCsvChange(event) {
csvFile.value = event.target.files[0] || null
}
</script>
<template>
<div class="create-session">
<h1>Create a Seating Chart</h1>
<p>Upload your seating layout image and a CSV file with student assignments.</p>
<form @submit.prevent="handleSubmit" class="form">
<div class="field">
<label for="image-input">Seating Layout Image</label>
<input
id="image-input"
type="file"
accept="image/*"
@change="onImageChange"
/>
</div>
<div class="field">
<label for="csv-input">Student CSV (columns: Student, Seat, Left handed?)</label>
<input
id="csv-input"
type="file"
accept=".csv"
@change="onCsvChange"
/>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? 'Creating...' : 'Create Session' }}
</button>
</form>
</div>
</template>
<style scoped>
.create-session {
max-width: 600px;
margin: 40px auto;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 30px;
text-align: left;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-weight: 600;
font-size: 0.9rem;
}
button {
padding: 12px 24px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #dc2626;
font-size: 0.9rem;
}
</style>
And here is the complete router/index.js file:
import { createRouter, createWebHistory } from 'vue-router'
import CreateSession from '../views/CreateSession.vue'
import GuestView from '../views/GuestView.vue'
import AdminView from '../views/AdminView.vue'
const routes = [
{ path: '/', name: 'home', component: CreateSession },
{ path: '/session/:id', name: 'guest', component: GuestView },
{ path: '/session/:id/admin', name: 'admin', component: AdminView },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
Your session creation flow is wired up and ready to process files. Next up, you'll build the admin view where the organizer calibrates seat positions on top of the uploaded image.
Build the Seat Map with Image Overlay and Calibration
In the last step, you built the session creation flow that uploads an image and CSV, then redirects the organizer to an admin page. That page does not exist yet.
In this step, you will build the admin view where the organizer sees their uploaded image, calibrates where the seats sit on it, and watches seat markers appear automatically. The key challenge is translating CSV seat codes like A-1 into visual dots positioned on top of an image using percentage-based coordinates.
In this step, get ready to:
- Create the AdminView component that loads session data and displays the uploaded image.
- Build a two-click calibration tool that defines where seats belong on the image.
- Auto-distribute seat markers within the calibrated area and color-code them by handedness.
Create AdminView.vue and load session data
When the organizer creates a session, CreateSession.vue redirects them to /session/:id/admin?token=:adminToken. The router already points that path to an AdminView component, but the file does not exist yet.
- Create a new file called AdminView.vue inside your src/views/ folder.
- Add the following <script setup> section to the file:
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '../lib/supabase.js'
const route = useRoute()
const sessionId = route.params.id
const adminToken = route.query.token
// Reactive state for session data and UI
const session = ref(null)
const seats = ref([])
const loading = ref(true)
const error = ref('')
const calibrating = ref(false)
const calibrationStep = ref(0)
const calibration = ref(null)
const imageRef = ref(null)
// Build the shareable guest link from the current session ID
const guestLink = computed(() => {
return `${window.location.origin}/session/${sessionId}`
})
// Fetch session and seat data on mount
onMounted(async () => {
try {
await supabase.auth.signInAnonymously()
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single()
if (sessionError) throw sessionError
// Verify the admin token before showing admin controls
if (sessionData.admin_token !== adminToken) {
error.value = 'Invalid admin token.'
loading.value = false
return
}
session.value = sessionData
calibration.value = sessionData.calibration || null
const { data: seatsData, error: seatsError } = await supabase
.from('seats')
.select('*')
.eq('session_id', sessionId)
if (seatsError) throw seatsError
seats.value = seatsData
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
</script>
What does this code do?
- sessionId and adminToken are pulled from the URL. The session ID comes from the route parameter, and the admin token from the query string.
- The calibrating, calibrationStep, and imageRef refs are declared here but used in the calibration tool you will build next.
- onMounted signs in anonymously for Supabase auth, fetches the session row, validates the admin token, and loads all seats for this session.
- guestLink is a computed property that builds the shareable URL guests will use to find their seats.
Now add the <template> section below the closing </script> tag. This renders the image inside a relative-positioned container.
- Add this template code below the </script> tag:
<template>
<div class="admin-view">
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<h1>Admin: Seating Chart</h1>
<div class="links">
<p><strong>Guest link:</strong> <code>{{ guestLink }}</code></p>
<button @click="navigator.clipboard.writeText(guestLink)">Copy Link</button>
</div>
<div class="map-container">
<img
ref="imageRef"
:src="session.image_url"
alt="Seating layout"
class="seating-image"
/>
</div>
</div>
</div>
</template>
Why position: relative on the container?
The .map-container div uses position: relative so that seat markers (which you will add later with position: absolute) are positioned relative to the image, not the whole page. Combined with percentage-based left and top values, the markers scale automatically when the image resizes.
- Add the <style scoped> section below the closing </template> tag:
<style scoped>
.admin-view {
max-width: 1000px;
margin: 0 auto;
}
.links {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
padding: 12px;
background: #f3f4f6;
border-radius: 8px;
}
.links code {
font-size: 0.85rem;
word-break: break-all;
}
.links button {
padding: 6px 14px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.map-container {
position: relative;
display: inline-block;
width: 100%;
}
.seating-image {
max-width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
.error {
color: #dc2626;
}
</style>
What do these styles do?
- .seating-image uses max-width: 100%; height: auto so the image scales responsively without distortion.
- .map-container is position: relative and display: inline-block so it wraps tightly around the image. This is the anchor point for absolutely positioned seat markers.
- Save AdminView.vue.
- Go back to your app at http://localhost:5173 and create a new session by uploading an image and CSV.
- After creating the session, you should land on the admin page showing your uploaded image, a guest link, and a Copy Link button.
Seeing a blank page or an error?
- Check your browser console for errors. If you see Invalid admin token, the token query parameter may have been lost during redirect. Go back to the home page and create a new session.
- If the image does not appear, make sure your seating-images Storage bucket has a SELECT policy allowing anon access. Without it, the public URL returns a 400 error.
- If you see a module not found error for AdminView, confirm the file is saved at src/views/AdminView.vue and restart your dev server.
Help me debug my AdminView page load
Build the calibration tool
The image is on screen, but the app does not know where the seating area is within the image. The organizer needs to tell it by clicking two corners: the top-left and bottom-right of the seating region.
Each click captures a percentage-based coordinate (0 to 1) so the calibration works regardless of image size. These four values define the bounding box where seats will appear.
- In AdminView.vue, find the closing }) of the onMounted block at the end of the <script setup> section.
- Add the following three functions below it:
// Reset calibration state and start the two-click flow
function startCalibration() {
calibrating.value = true
calibrationStep.value = 0
calibration.value = {}
}
// Capture percentage-based click coordinates on the image
function handleImageClick(event) {
if (!calibrating.value) return
const img = imageRef.value
const x = event.offsetX / img.offsetWidth
const y = event.offsetY / img.offsetHeight
if (calibrationStep.value === 0) {
calibration.value.topLeftX = x
calibration.value.topLeftY = y
calibrationStep.value = 1
} else if (calibrationStep.value === 1) {
calibration.value.bottomRightX = x
calibration.value.bottomRightY = y
calibrating.value = false
saveCalibration()
}
}
// Persist the bounding box to the sessions table
async function saveCalibration() {
const { error: saveError } = await supabase
.from('sessions')
.update({ calibration: calibration.value })
.eq('id', sessionId)
if (saveError) {
error.value = 'Failed to save calibration: ' + saveError.message
}
}
What does this code do?
- startCalibration() flips the component into calibration mode. The first click sets the top-left corner, and the second click sets the bottom-right corner.
- handleImageClick() divides event.offsetX by the image width to get a value between 0 and 1. This percentage stays accurate even if the browser window resizes.
- saveCalibration() writes the four bounding box values to the calibration JSONB column in Supabase so the data persists across page reloads.
The calibration functions are ready, but the template does not use them yet. You need to add the calibration controls and wire up the image click handler.
- In the <template> section, find the closing </div> of the links div.
- Add this calibration controls block between the links div and the map-container div:
<div class="calibration-controls">
<button v-if="!calibrating" @click="startCalibration">
{{ calibration && calibration.bottomRightX ? 'Recalibrate' : 'Calibrate Seats' }}
</button>
<p v-if="calibrating && calibrationStep === 0" class="instruction">
Click the TOP-LEFT corner of the seating area.
</p>
<p v-if="calibrating && calibrationStep === 1" class="instruction">
Click the BOTTOM-RIGHT corner of the seating area.
</p>
</div>
- Next, update the <img> tag inside map-container to add the click handler and calibrating cursor.
- Find the current <img> tag:
<img
ref="imageRef"
:src="session.image_url"
alt="Seating layout"
class="seating-image"
/>
- Replace it with this updated version that includes @click and :class bindings:
<img
ref="imageRef"
:src="session.image_url"
alt="Seating layout"
class="seating-image"
@click="handleImageClick"
:class="{ calibrating: calibrating }"
/>
- Add these calibration styles to the bottom of your <style scoped> section, before the closing </style> tag:
.seating-image.calibrating {
cursor: crosshair;
}
.calibration-controls {
margin: 16px 0;
}
.calibration-controls button {
padding: 10px 20px;
background: #059669;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.instruction {
color: #059669;
font-weight: 600;
margin-top: 8px;
}
What does the calibration UI do?
- The button text switches between Calibrate Seats and Recalibrate depending on whether calibration data already exists.
- During calibration, the image cursor changes to a crosshair so the organizer knows they are in click mode.
- Green instruction text guides the organizer through the two clicks.
- Save AdminView.vue and refresh your admin page.
- You should see a green Calibrate Seats button below the guest link.
- Click the button, then click two points on your image. The instruction text should change after the first click, and the button should reappear as Recalibrate after the second click.
Clicks not registering on the image?
- Make sure you added @click="handleImageClick" to the <img> tag. Without it, clicking the image does nothing.
- If the cursor does not change to a crosshair, check that :class="{ calibrating: calibrating }" is on the <img> tag and the .seating-image.calibrating CSS rule exists.
Help me debug the calibration click handler
Auto-distribute seats within the bounding box
The bounding box is saved, but there are no seat markers on the image yet. Each seat in the CSV has a code like A-1 or B-3. The letter represents the row and the number represents the column.
A computed property will parse these codes, figure out the grid structure, and calculate each seat's percentage position within the bounding box.
- In the <script setup> section of AdminView.vue, find the saveCalibration() function you just added.
- Add this computed property below it:
// Calculate seat positions as percentages within the calibrated bounding box
const seatPositions = computed(() => {
if (!calibration.value || !calibration.value.bottomRightX) return []
const cal = calibration.value
const boxWidth = cal.bottomRightX - cal.topLeftX
const boxHeight = cal.bottomRightY - cal.topLeftY
// Group seats by row letter (e.g., "A-1" -> row "A", column 1)
const rows = {}
seats.value.forEach((seat) => {
const parts = seat.seat_code.split('-')
const rowLetter = parts[0]
const colNumber = parseInt(parts[1], 10)
if (!rows[rowLetter]) rows[rowLetter] = []
rows[rowLetter].push({ ...seat, colNumber })
})
const rowKeys = Object.keys(rows).sort()
const totalRows = rowKeys.length
// Find the highest column number across all rows
let maxCol = 0
Object.values(rows).forEach((rowSeats) => {
rowSeats.forEach((s) => {
if (s.colNumber > maxCol) maxCol = s.colNumber
})
})
// Distribute each seat evenly within the bounding box
const positions = []
rowKeys.forEach((rowLetter, rowIndex) => {
rows[rowLetter].forEach((seat) => {
const x = cal.topLeftX + ((seat.colNumber - 1) / (maxCol - 1 || 1)) * boxWidth
const y = cal.topLeftY + (rowIndex / (totalRows - 1 || 1)) * boxHeight
positions.push({
...seat,
posX: x * 100,
posY: y * 100,
})
})
})
return positions
})
What does this code do?
- It splits each seat code on the - character. A-1 becomes row A, column 1.
- The rows are sorted alphabetically, so row A is at the top and row Z is at the bottom.
- Each seat's X position is calculated as a fraction of the bounding box width based on its column index. Y is based on its row index.
- The || 1 fallback prevents division by zero when there is only one row or one column.
- Final values are multiplied by 100 to convert from 0-1 fractions to CSS percentage values.
Now render the seat markers on top of the image. Each marker is an absolutely positioned dot with a tooltip.
- In the <template> section, find the <img> tag inside map-container.
- Add this seat marker loop directly after the closing /> of the <img> tag, but still inside map-container:
<div
v-for="seat in seatPositions"
:key="seat.id"
class="seat-marker"
:class="{ 'left-handed': seat.left_handed }"
:style="{ left: seat.posX + '%', top: seat.posY + '%' }"
:title="seat.student_name + ' (' + seat.seat_code + ')'"
>
<span class="seat-label">{{ seat.seat_code }}</span>
</div>
What does this template code do?
- Each seat from seatPositions renders as a small circle positioned with left and top percentages on top of the image.
- The left-handed class applies a different color to left-handed seats (orange instead of blue).
- The :title attribute shows the student name and seat code as a native browser tooltip on hover.
- The seat-label span is hidden by default and shown on hover via CSS for a styled tooltip.
- Add the seat marker styles to the bottom of your <style scoped> section:
.seat-marker {
position: absolute;
width: 14px;
height: 14px;
background: #3b82f6;
border: 2px solid #1d4ed8;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.2s;
}
.seat-marker:hover {
transform: translate(-50%, -50%) scale(1.5);
z-index: 10;
}
.seat-marker.left-handed {
background: #f59e0b;
border-color: #d97706;
}
.seat-label {
display: none;
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
white-space: nowrap;
}
.seat-marker:hover .seat-label {
display: block;
}
Why translate(-50%, -50%) on each marker?
Without the transform, the top-left corner of each dot sits at the calculated coordinate. translate(-50%, -50%) shifts the dot so its center lands on the coordinate instead. This makes the seat positions look accurate.
- Save AdminView.vue and refresh the admin page.
- Click Calibrate Seats (or Recalibrate), then click two corners on your image to define the seating area.
- After the second click, blue dots should appear spread across the bounding box. Left-handed seats appear as orange dots.
- Hover over any dot. You should see the seat code label pop up above the marker.
No dots appearing after calibration?
- Open your browser's Developer Tools console and check for errors. If seatPositions returns an empty array, the calibration data may not have saved. Verify in your Supabase dashboard that the calibration column in the sessions table has JSON data with all four keys.
- If the dots appear but are all stacked in one spot, check that your CSV seat codes follow the LETTER-NUMBER format (e.g., A-1, B-3). The parser splits on - to find the row and column.
- If dots appear outside the image, make sure .map-container has position: relative in its CSS.
Help me debug why seat markers are not showing up
Awesome, I've got everything!
Make sure you have saved AdminView.vue.
I'd like to double check the full code
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '../lib/supabase.js'
const route = useRoute()
const sessionId = route.params.id
const adminToken = route.query.token
const session = ref(null)
const seats = ref([])
const loading = ref(true)
const error = ref('')
const calibrating = ref(false)
const calibrationStep = ref(0)
const calibration = ref(null)
const imageRef = ref(null)
const guestLink = computed(() => {
return `${window.location.origin}/session/${sessionId}`
})
onMounted(async () => {
try {
await supabase.auth.signInAnonymously()
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single()
if (sessionError) throw sessionError
if (sessionData.admin_token !== adminToken) {
error.value = 'Invalid admin token.'
loading.value = false
return
}
session.value = sessionData
calibration.value = sessionData.calibration || null
const { data: seatsData, error: seatsError } = await supabase
.from('seats')
.select('*')
.eq('session_id', sessionId)
if (seatsError) throw seatsError
seats.value = seatsData
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
function startCalibration() {
calibrating.value = true
calibrationStep.value = 0
calibration.value = {}
}
function handleImageClick(event) {
if (!calibrating.value) return
const img = imageRef.value
const x = event.offsetX / img.offsetWidth
const y = event.offsetY / img.offsetHeight
if (calibrationStep.value === 0) {
calibration.value.topLeftX = x
calibration.value.topLeftY = y
calibrationStep.value = 1
} else if (calibrationStep.value === 1) {
calibration.value.bottomRightX = x
calibration.value.bottomRightY = y
calibrating.value = false
saveCalibration()
}
}
async function saveCalibration() {
const { error: saveError } = await supabase
.from('sessions')
.update({ calibration: calibration.value })
.eq('id', sessionId)
if (saveError) {
error.value = 'Failed to save calibration: ' + saveError.message
}
}
const seatPositions = computed(() => {
if (!calibration.value || !calibration.value.bottomRightX) return []
const cal = calibration.value
const boxWidth = cal.bottomRightX - cal.topLeftX
const boxHeight = cal.bottomRightY - cal.topLeftY
const rows = {}
seats.value.forEach((seat) => {
const parts = seat.seat_code.split('-')
const rowLetter = parts[0]
const colNumber = parseInt(parts[1], 10)
if (!rows[rowLetter]) rows[rowLetter] = []
rows[rowLetter].push({ ...seat, colNumber })
})
const rowKeys = Object.keys(rows).sort()
const totalRows = rowKeys.length
let maxCol = 0
Object.values(rows).forEach((rowSeats) => {
rowSeats.forEach((s) => {
if (s.colNumber > maxCol) maxCol = s.colNumber
})
})
const positions = []
rowKeys.forEach((rowLetter, rowIndex) => {
rows[rowLetter].forEach((seat) => {
const x = cal.topLeftX + ((seat.colNumber - 1) / (maxCol - 1 || 1)) * boxWidth
const y = cal.topLeftY + (rowIndex / (totalRows - 1 || 1)) * boxHeight
positions.push({
...seat,
posX: x * 100,
posY: y * 100,
})
})
})
return positions
})
</script>
<template>
<div class="admin-view">
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<h1>Admin: Seating Chart</h1>
<div class="links">
<p><strong>Guest link:</strong> <code>{{ guestLink }}</code></p>
<button @click="navigator.clipboard.writeText(guestLink)">Copy Link</button>
</div>
<div class="calibration-controls">
<button v-if="!calibrating" @click="startCalibration">
{{ calibration && calibration.bottomRightX ? 'Recalibrate' : 'Calibrate Seats' }}
</button>
<p v-if="calibrating && calibrationStep === 0" class="instruction">
Click the TOP-LEFT corner of the seating area.
</p>
<p v-if="calibrating && calibrationStep === 1" class="instruction">
Click the BOTTOM-RIGHT corner of the seating area.
</p>
</div>
<div class="map-container">
<img
ref="imageRef"
:src="session.image_url"
alt="Seating layout"
class="seating-image"
@click="handleImageClick"
:class="{ calibrating: calibrating }"
/>
<div
v-for="seat in seatPositions"
:key="seat.id"
class="seat-marker"
:class="{ 'left-handed': seat.left_handed }"
:style="{ left: seat.posX + '%', top: seat.posY + '%' }"
:title="seat.student_name + ' (' + seat.seat_code + ')'"
>
<span class="seat-label">{{ seat.seat_code }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin-view {
max-width: 1000px;
margin: 0 auto;
}
.links {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0;
padding: 12px;
background: #f3f4f6;
border-radius: 8px;
}
.links code {
font-size: 0.85rem;
word-break: break-all;
}
.links button {
padding: 6px 14px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.calibration-controls {
margin: 16px 0;
}
.calibration-controls button {
padding: 10px 20px;
background: #059669;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.instruction {
color: #059669;
font-weight: 600;
margin-top: 8px;
}
.map-container {
position: relative;
display: inline-block;
width: 100%;
}
.seating-image {
max-width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
.seating-image.calibrating {
cursor: crosshair;
}
.seat-marker {
position: absolute;
width: 14px;
height: 14px;
background: #3b82f6;
border: 2px solid #1d4ed8;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.2s;
}
.seat-marker:hover {
transform: translate(-50%, -50%) scale(1.5);
z-index: 10;
}
.seat-marker.left-handed {
background: #f59e0b;
border-color: #d97706;
}
.seat-label {
display: none;
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
white-space: nowrap;
}
.seat-marker:hover .seat-label {
display: block;
}
.error {
color: #dc2626;
}
</style>
Your admin view now loads session data, lets the organizer calibrate the seating area with two clicks, and renders color-coded seat markers at the right positions. Next up, you will build the guest view where visitors type their name and see their assigned seat light up.
Build the Guest View with Name Lookup
Your organizer can now create sessions, upload images, and calibrate seat positions. The admin side of the app is complete.
Now you need the other half: the guest experience. When someone visits the shareable link, they should see the seating chart and be able to type their name to find exactly where they sit. You will build a read-only view that fetches the same session data, renders the seat map without calibration tools, and highlights a matching seat with a pulsing animation.
In this step, get ready to:
- Create the GuestView component that fetches and displays the seat map.
- Add a name search feature with pulsing seat highlighting.
- Verify all three routes work end to end.
Create GuestView and fetch session data
The guest view needs to load the same session and seat data as the admin view. It uses the session ID from the URL to query Supabase, then calculates seat positions using the stored calibration data. The key difference is that guests get no calibration tools and no admin token check.
- Create a new file at src/views/GuestView.vue in VS Code.
- Add the following <script setup> block as the first section of the file:
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '../lib/supabase.js'
const route = useRoute()
const sessionId = route.params.id
// Reactive state for session data and UI
const session = ref(null)
const seats = ref([])
const loading = ref(true)
const error = ref('')
const searchName = ref('')
const matchedSeatId = ref(null)
const notFound = ref(false)
// Fetch session and seat data when the component loads
onMounted(async () => {
try {
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single()
if (sessionError) throw sessionError
session.value = sessionData
const { data: seatsData, error: seatsError } = await supabase
.from('seats')
.select('*')
.eq('session_id', sessionId)
if (seatsError) throw seatsError
seats.value = seatsData
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
// Extract calibration from the session data
const calibration = computed(() => session.value?.calibration || null)
// Calculate percentage-based positions for each seat within the bounding box
const seatPositions = computed(() => {
if (!calibration.value || !calibration.value.bottomRightX) return []
const cal = calibration.value
const boxWidth = cal.bottomRightX - cal.topLeftX
const boxHeight = cal.bottomRightY - cal.topLeftY
// Group seats by row letter
const rows = {}
seats.value.forEach((seat) => {
const parts = seat.seat_code.split('-')
const rowLetter = parts[0]
const colNumber = parseInt(parts[1], 10)
if (!rows[rowLetter]) rows[rowLetter] = []
rows[rowLetter].push({ ...seat, colNumber })
})
const rowKeys = Object.keys(rows).sort()
const totalRows = rowKeys.length
// Find the highest column number across all rows
let maxCol = 0
Object.values(rows).forEach((rowSeats) => {
rowSeats.forEach((s) => {
if (s.colNumber > maxCol) maxCol = s.colNumber
})
})
// Distribute seats evenly within the calibration bounding box
const positions = []
rowKeys.forEach((rowLetter, rowIndex) => {
rows[rowLetter].forEach((seat) => {
const x = cal.topLeftX + ((seat.colNumber - 1) / (maxCol - 1 || 1)) * boxWidth
const y = cal.topLeftY + (rowIndex / (totalRows - 1 || 1)) * boxHeight
positions.push({
...seat,
posX: x * 100,
posY: y * 100,
})
})
})
return positions
})
// Search for a student by name and highlight their seat
function findSeat() {
notFound.value = false
matchedSeatId.value = null
if (!searchName.value.trim()) return
// Case-insensitive exact match
const match = seats.value.find(
(s) => s.student_name.toLowerCase() === searchName.value.trim().toLowerCase()
)
if (match) {
matchedSeatId.value = match.id
const el = document.getElementById(`seat-${match.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} else {
notFound.value = true
}
}
</script>
What does this script do?
- onMounted fetches the session row and all its seats from Supabase using the :id route parameter. No anonymous sign-in is needed here because the RLS policies allow anon SELECT access.
- seatPositions uses the same grid calculation logic as AdminView. It parses seat codes like A-1 into row/column pairs and distributes them evenly within the calibration bounding box.
- findSeat() does a case-insensitive exact match against the loaded seats array. If it finds a match, it stores the seat ID in matchedSeatId and smooth-scrolls the marker into view.
You will not see anything in the browser yet. The component needs its template and styles before it can render.
Add the name search and seat map template
The template combines two pieces: a search bar where the guest types their name, and the seat map overlay from the admin view (minus the calibration controls).
- In GuestView.vue, add the following <template> block below the closing </script> tag:
<template>
<div class="guest-view">
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<h1>Find Your Seat</h1>
<!-- Name search bar -->
<div class="search-bar">
<input
v-model="searchName"
type="text"
placeholder="Enter your name..."
@keyup.enter="findSeat"
/>
<button @click="findSeat">Find</button>
</div>
<p v-if="notFound" class="not-found">Name not found. Please check the spelling.</p>
<!-- Seat map overlay on the uploaded image -->
<div class="map-container">
<img
:src="session.image_url"
alt="Seating layout"
class="seating-image"
/>
<div
v-for="seat in seatPositions"
:key="seat.id"
:id="'seat-' + seat.id"
class="seat-marker"
:class="{
'left-handed': seat.left_handed,
'highlighted': matchedSeatId === seat.id,
}"
:style="{ left: seat.posX + '%', top: seat.posY + '%' }"
:title="seat.student_name + ' (' + seat.seat_code + ')'"
>
<!-- Show name popup only on the matched seat -->
<span v-if="matchedSeatId === seat.id" class="seat-name-popup">
{{ seat.student_name }} - {{ seat.seat_code }}
</span>
</div>
</div>
</div>
</div>
</template>
What does this template do?
- The search bar uses v-model="searchName" to two-way bind the input value. Pressing Enter triggers findSeat through the @keyup.enter event modifier.
- Each seat marker gets an :id attribute so findSeat() can look it up with document.getElementById and scroll it into view.
- The highlighted class is conditionally applied only when the seat's ID matches matchedSeatId. The seat-name-popup span only renders on the matched seat, showing the student name and seat code.
The template is in place, but the seat markers and search bar need CSS to display correctly. Time to add the styles.
- Add the following <style scoped> block below the closing </template> tag:
<style scoped>
.guest-view {
max-width: 1000px;
margin: 0 auto;
text-align: center;
}
.search-bar {
display: flex;
gap: 8px;
justify-content: center;
margin: 20px 0;
}
.search-bar input {
padding: 12px 16px;
font-size: 1rem;
border: 2px solid #d1d5db;
border-radius: 8px;
width: 300px;
}
.search-bar input:focus {
outline: none;
border-color: #4f46e5;
}
.search-bar button {
padding: 12px 24px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
.not-found {
color: #dc2626;
margin: 8px 0;
}
.map-container {
position: relative;
display: inline-block;
width: 100%;
margin-top: 20px;
}
.seating-image {
max-width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
.seat-marker {
position: absolute;
width: 14px;
height: 14px;
background: #3b82f6;
border: 2px solid #1d4ed8;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.seat-marker:hover {
transform: translate(-50%, -50%) scale(1.4);
z-index: 10;
}
.seat-marker.left-handed {
background: #f59e0b;
border-color: #d97706;
}
.seat-marker.highlighted {
background: #10b981;
border-color: #059669;
transform: translate(-50%, -50%) scale(1.8);
z-index: 20;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
}
50% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
}
.seat-name-popup {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8rem;
white-space: nowrap;
font-weight: 600;
}
.error {
color: #dc2626;
}
</style>
What do the highlighted styles do?
- .highlighted turns the matched seat green, scales it up to 1.8x its normal size, and adds a soft green glow with box-shadow.
- The @keyframes pulse animation makes the glow expand and fade repeatedly, drawing the guest's eye to their assigned seat.
- .seat-name-popup is a dark tooltip that floats above the highlighted seat, displaying the student name and seat code.
- Save GuestView.vue.
- Make sure your dev server is running with npm run dev.
- Navigate to your guest URL in the browser (the /session/:id link from the admin view). You should see the "Find Your Seat" heading, a search bar, and the seating chart image with blue seat markers overlaid on it.
Don't see the seat markers?
- If the page says "Loading..." and never finishes, check that the session ID in your URL matches a real session in your Supabase database.
- If the image appears but no markers show, the session might not have calibration data saved. Go back to the admin view and click the two calibration corners first.
- If you see an error message, open the browser console (F12) and check for Supabase query errors. Make sure your RLS policies allow anon SELECT on both the sessions and seats tables.
- Why aren't my seat markers appearing on the guest view?
Test the name search and highlighting
The guest view is built and the search function is ready. Time to test the full lookup flow.
- In the guest view, type a student name from your CSV file into the search bar.
- Press Enter or click Find.
You should see one seat marker turn green, scale up larger than the others, and pulse with a glowing animation. A dark tooltip above the marker displays the student name and seat code. The page smooth-scrolls to center the highlighted seat in your viewport.
- Try typing a name that does not exist in your CSV.
You should see the red "Name not found. Please check the spelling." message appear below the search bar.
Why case-insensitive matching?
Guests might type "john smith" instead of "John Smith". The .toLowerCase() comparison on both sides ensures the search works regardless of capitalization. The match is still an exact match on the full name, so "John" alone would not match "John Smith".
Seat turns green but doesn't scroll?
- Check that the :id="'seat-' + seat.id" attribute is on each seat marker div in the template. Without this, document.getElementById returns null and the scroll is silently skipped.
- If the marker does not change color at all, make sure the name you typed matches exactly (first and last name). Check your CSV to confirm the spelling.
- My seat highlighting or scroll isn't working on the guest view.
Verify all three routes work end to end
The Vue Router configuration in src/router/index.js was set up in an earlier step with all three routes already wired. Now that GuestView.vue exists, every route resolves to a working component.
How do the three routes connect?
- / loads CreateSession, where the organizer uploads files and creates a new session.
- /session/:id/admin?token=... loads AdminView. The admin token in the query string is checked against the session's admin_token column. If it does not match, the view shows an error.
- /session/:id loads GuestView. This is the shareable link that guests visit. No token is required.
- Walk through the full flow to confirm everything works:
- Visit http://localhost:5173 in your browser. You should see the Create Session form.
- Upload a seating layout image and a CSV file, then click Create Session.
- On the admin page, calibrate the seats by clicking two corners.
- Copy the guest link and open it in a new browser tab.
- Type a student name into the search bar and press Enter. Confirm the matching seat turns green and pulses.
Pro tip: admin token as a route guard
The admin view checks the token query parameter against the session's stored admin_token inside the component's onMounted hook. This acts as a lightweight route guard without needing Vue Router's beforeEnter hook. Anyone who visits the admin URL without the correct token sees an error instead of the calibration tools.
Awesome, I've got everything!
Double check you have saved GuestView.vue and confirmed the full flow works.
I'd like to double check the full code
Here is the complete src/views/GuestView.vue file:
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { supabase } from '../lib/supabase.js'
const route = useRoute()
const sessionId = route.params.id
const session = ref(null)
const seats = ref([])
const loading = ref(true)
const error = ref('')
const searchName = ref('')
const matchedSeatId = ref(null)
const notFound = ref(false)
onMounted(async () => {
try {
const { data: sessionData, error: sessionError } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single()
if (sessionError) throw sessionError
session.value = sessionData
const { data: seatsData, error: seatsError } = await supabase
.from('seats')
.select('*')
.eq('session_id', sessionId)
if (seatsError) throw seatsError
seats.value = seatsData
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
const calibration = computed(() => session.value?.calibration || null)
const seatPositions = computed(() => {
if (!calibration.value || !calibration.value.bottomRightX) return []
const cal = calibration.value
const boxWidth = cal.bottomRightX - cal.topLeftX
const boxHeight = cal.bottomRightY - cal.topLeftY
const rows = {}
seats.value.forEach((seat) => {
const parts = seat.seat_code.split('-')
const rowLetter = parts[0]
const colNumber = parseInt(parts[1], 10)
if (!rows[rowLetter]) rows[rowLetter] = []
rows[rowLetter].push({ ...seat, colNumber })
})
const rowKeys = Object.keys(rows).sort()
const totalRows = rowKeys.length
let maxCol = 0
Object.values(rows).forEach((rowSeats) => {
rowSeats.forEach((s) => {
if (s.colNumber > maxCol) maxCol = s.colNumber
})
})
const positions = []
rowKeys.forEach((rowLetter, rowIndex) => {
rows[rowLetter].forEach((seat) => {
const x = cal.topLeftX + ((seat.colNumber - 1) / (maxCol - 1 || 1)) * boxWidth
const y = cal.topLeftY + (rowIndex / (totalRows - 1 || 1)) * boxHeight
positions.push({
...seat,
posX: x * 100,
posY: y * 100,
})
})
})
return positions
})
function findSeat() {
notFound.value = false
matchedSeatId.value = null
if (!searchName.value.trim()) return
const match = seats.value.find(
(s) => s.student_name.toLowerCase() === searchName.value.trim().toLowerCase()
)
if (match) {
matchedSeatId.value = match.id
const el = document.getElementById(`seat-${match.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} else {
notFound.value = true
}
}
</script>
<template>
<div class="guest-view">
<div v-if="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<h1>Find Your Seat</h1>
<div class="search-bar">
<input
v-model="searchName"
type="text"
placeholder="Enter your name..."
@keyup.enter="findSeat"
/>
<button @click="findSeat">Find</button>
</div>
<p v-if="notFound" class="not-found">Name not found. Please check the spelling.</p>
<div class="map-container">
<img
:src="session.image_url"
alt="Seating layout"
class="seating-image"
/>
<div
v-for="seat in seatPositions"
:key="seat.id"
:id="'seat-' + seat.id"
class="seat-marker"
:class="{
'left-handed': seat.left_handed,
'highlighted': matchedSeatId === seat.id,
}"
:style="{ left: seat.posX + '%', top: seat.posY + '%' }"
:title="seat.student_name + ' (' + seat.seat_code + ')'"
>
<span v-if="matchedSeatId === seat.id" class="seat-name-popup">
{{ seat.student_name }} - {{ seat.seat_code }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.guest-view {
max-width: 1000px;
margin: 0 auto;
text-align: center;
}
.search-bar {
display: flex;
gap: 8px;
justify-content: center;
margin: 20px 0;
}
.search-bar input {
padding: 12px 16px;
font-size: 1rem;
border: 2px solid #d1d5db;
border-radius: 8px;
width: 300px;
}
.search-bar input:focus {
outline: none;
border-color: #4f46e5;
}
.search-bar button {
padding: 12px 24px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
}
.not-found {
color: #dc2626;
margin: 8px 0;
}
.map-container {
position: relative;
display: inline-block;
width: 100%;
margin-top: 20px;
}
.seating-image {
max-width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
.seat-marker {
position: absolute;
width: 14px;
height: 14px;
background: #3b82f6;
border: 2px solid #1d4ed8;
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.seat-marker:hover {
transform: translate(-50%, -50%) scale(1.4);
z-index: 10;
}
.seat-marker.left-handed {
background: #f59e0b;
border-color: #d97706;
}
.seat-marker.highlighted {
background: #10b981;
border-color: #059669;
transform: translate(-50%, -50%) scale(1.8);
z-index: 20;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.3);
}
50% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
}
.seat-name-popup {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8rem;
white-space: nowrap;
font-weight: 600;
}
.error {
color: #dc2626;
}
</style>
Secret mission
Search-as-You-Type Seat Highlighting
Take your guest view to the next level. Instead of waiting for the guest to press Enter, matching seats light up in real time as they type. Unmatched seats fade away, and when only one match remains, the view auto-scrolls to center on it.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Supabase free tier (500MB database, 1GB file storage, 50K monthly active users), so there are no ongoing costs as long as you stay within those limits.
Resources you used:
- Supabase project (database tables: sessions and seats, RLS policies, anonymous auth).
- Supabase Storage bucket: seating-images.
- Local Vue project: seating-chart directory with installed node_modules.
- Vite dev server (if still running on http://localhost:5173).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your Supabase project stays active on the free tier with no charges.
- Your local project files and node_modules remain on disk.
- You can restart the dev server anytime by running npm run dev from the seating-chart directory.
Pause - I'll come back to this later
Shut down running processes to free up local memory. Your Supabase project and local files stay intact for when you return.
- Stop the Vite dev server by pressing Ctrl + C in the terminal where it is running.
- Your Supabase project will remain on the free tier. Supabase may pause inactive free-tier projects after a period of inactivity, but you can restore them from the dashboard.
- Your local seating-chart folder stays on your computer, ready to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for your Supabase data.
- Stop the Vite dev server by pressing Ctrl + C in the terminal.
- Delete the local project folder by deleting the seating-chart directory from your file system.
- In the Supabase dashboard, open your project.
- Go to Storage and delete all files in the seating-images bucket. Then delete the bucket itself.
- Go to Project Settings, then General. Scroll to the bottom and click Delete project to remove the entire Supabase project and all its data.
Mission accomplished
Nice Work!
You just built a full-stack seating chart visualizer with Vue 3 and Supabase. An organizer can upload an image and CSV, calibrate the seat positions, and share a link where guests instantly find their assigned seat.
Here's what you learned:
- Built a Vue 3 SPA with organizer and guest flows connected by shareable session URLs, backed by a Supabase database with anonymous auth, RLS policies, and file storage.
- Parsed CSV files with PapaParse to extract a row/column grid structure from seat codes, then overlaid percentage-based seat markers on an uploaded image using a bounding box calibration tool.
- Created a guest name-lookup feature that highlights the matching seat with a pulsing CSS animation and scrolls it into view.
- Added search-as-you-type filtering that highlights partial matches in real time and auto-scrolls when a single result remains.
Ready to quiz yourself?