Build a SkillsUSA Media Hub

Build a responsive media hub with Supabase Auth, RLS, and resumable uploads.

Introduction

30 Second Summary

Chapter flyers, event photos, and videos often end up scattered across messages or shared drives. Visitors can easily miss the latest update.

In this project, you will build a SkillsUSA media hub with a responsive application shell for public browsing. Supabase powers a protected admin workspace through Row Level Security plus resumable uploads with visible progress.

What You'll Build

Picture opening one polished feed on a desktop or phone to browse image flyers, event photos, PDF links, and playable chapter videos.

By the end of this project, you'll have:

  • A responsive public feed you can resize from a desktop layout to a mobile layout. Navigation stays usable without horizontal page scrolling.
  • Flexible media cards that display image flyers or photos. PDF flyers open through a clear View flyer action. Short videos play inside the feed.
  • A protected publishing workspace where admins preview files before uploading them. Visible progress reaches 100% before the published media appears in the public feed.
  • Secret Mission: Prove the admin boundary with a second account. Deliberately promote that account through explicit admin membership.

Are there any prerequisites?

You'll need a Windows computer with Visual Studio Code. You'll also need a personal Supabase account.

Step 1 checks Node.js, npm, and Microsoft Edge before the build begins.

Before We Start

This step locks in a focused first release for visitors: a public SkillsUSA media feed. Publishing stays with explicitly approved admins because authentication alone does not grant that authority.

Set Up the Windows Project

Your media hub needs a verified local toolchain before the first page can run. Checking each tool now keeps installation problems from interrupting that visible build.

You will verify Node.js v24.20.0 LTS with npm 11. You will also prepare the project in Visual Studio Code before opening its temporary Vite page in Microsoft Edge.

In this step, get ready to:
  • Verify the Windows toolchain.
  • Create the local project configuration.
  • Launch the temporary starter page with installed dependencies.
Verify Node.js and Microsoft Edge

Node.js runs the development tools behind your project. PowerShell lets you confirm that the required runtime and package manager are available.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open PowerShell.
  • Check the installed Node.js and npm versions by running these commands:
node --version
npm --version

What Do These Checks Show?

  • The first command prints the installed Node.js version.
  • The second command prints the installed npm version.

✔️ I see the required versions

Look for v24.20.0 in the Node.js output. The npm output should start with 11.

Your Windows runtime is ready for the project.

ⓧ I see older versions

This project uses Node.js v24.20.0 LTS and npm 11. Installing the pinned LTS release updates the toolchain used by the project.

  • Visit the official Node.js download page.
  • Choose the Windows installer for Node.js v24.20.0 LTS.
  • Run the downloaded installer.
  • Keep the default installer options.
  • Close PowerShell after the installation finishes.
  • Open a fresh PowerShell window through Windows search.
  • Run both version checks shown above again.

ⓧ Command not found

Windows cannot find Node.js yet. Installing Node.js v24.20.0 LTS also provides npm for this project.

  • Visit the official Node.js download page.
  • Choose the Windows installer for Node.js v24.20.0 LTS.
  • Run the downloaded installer.
  • Keep the default installer options.
  • Close PowerShell after the installation finishes.
  • Open a fresh PowerShell window through Windows search.
  • Run both version checks shown above again.

Still Not Seeing the Versions?

Close every PowerShell window after installing Node.js. A fresh window reloads the command locations available to Windows.

Still blocked? Help me troubleshoot why PowerShell cannot find my Node.js and npm installation.

Microsoft Edge provides the browser where you will inspect the local app. Confirm that it opens before creating the project.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge into the search field.
  • Press Enter.

✔️ Microsoft Edge opens

Your browser is ready to display the local Vite page.

ⓧ Microsoft Edge is unavailable

Install Microsoft Edge from Microsoft's official download page before continuing.

  • Visit the official Microsoft Edge download page.
  • Select Download for Windows.
  • Run the downloaded installer.
  • Return to Windows search after the installation finishes.
  • Type Microsoft Edge into the search field.
  • Press Enter.

Microsoft Edge should open to a browser window.

Create the project folder and configuration

A Visual Studio Code workspace keeps your project files and terminal focused on the same folder. You will create that folder on your Desktop so it stays easy to find.

  • Press Windows+E to open File Explorer.
  • Select Desktop in the navigation pane.
  • Press Ctrl+Shift+N to create a folder.
  • Type skillsusa-chapter-media-hub as the folder name.
  • Press Enter to save the folder name.

That gives every project file a clear home on your Desktop.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.

Visual Studio Code may show a Workspace Trust dialog after opening the folder. The folder is yours, so you can enable its workspace features.

  • Select File in the menu bar.
  • Select Open Folder....
  • Choose the skillsusa-chapter-media-hub folder from your Desktop.
  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer now shows skillsusa-chapter-media-hub as the open workspace.

The project manifest defines the commands and pinned dependencies that npm installs. It keeps every learner on the same versions of Vite, the Supabase browser client, and the resumable upload client.

  • Select the Explorer view in the Visual Studio Code Activity Bar.
  • Select the New File... button in the Explorer.
  • Type package.json as the file name.
  • Press Enter.
  • Add the project manifest by pasting this code:
{
  "name": "skillsusa-chapter-media-hub",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@supabase/supabase-js": "2.115.0",
    "tus-js-client": "4.3.1"
  },
  "devDependencies": {
    "vite": "8.2.2"
  }
}

What Does This File Configure?

  • The scripts section defines the Vite development command used in this project.
  • The dependencies section pins the Supabase browser client and tus upload client.
  • The devDependencies section pins Vite to 8.2.2.
  • Save package.json by pressing Ctrl+S.

The Explorer should list package.json inside skillsusa-chapter-media-hub.

Is package.json Showing an Error?

Confirm that the file name ends with .json. Check that every quoted key matches the reference exactly.

Need another pair of eyes? Help me find the formatting problem in my package.json file.

The environment example records the two browser configuration names used later. Its values are safe placeholders, so no live project key belongs in this file.

  • Select the New File... button in the Explorer.
  • Type .env.example as the file name.
  • Press Enter.

The Explorer now lists .env.example beside package.json.

  • Add the environment-variable template by pasting this configuration:
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

Why Keep an Environment Example?

The VITE_ prefix makes these values available to browser code through Vite. The example documents the required names without storing a real Supabase project value.

  • Save .env.example by pressing Ctrl+S.

The saved file should contain exactly two configuration lines.

Does the Environment File Look Different?

Confirm that the file is named .env.example with the leading dot. Keep each environment variable on its own line.

Still unsure? Help me check the name and contents of my .env.example file.

Launch the temporary starter page

The saved manifest gives npm an exact dependency list. Installing it creates the local packages that power the development server and later Supabase uploads.

  • Select View in the Visual Studio Code menu bar.
  • Select Terminal.
  • Choose PowerShell from the terminal profile menu if another shell is selected.
  • Install the dependencies from package.json by running this command:
npm install

What Does npm Install Create?

npm reads package.json and downloads the pinned packages into node_modules. It also records the resolved dependency tree in package-lock.json.

The first install can spend some time downloading packages. Let PowerShell return to its prompt before continuing.

Once it finishes, PowerShell reports that packages were added. Your local dependencies are ready.

Did the Installation Fail?

Confirm that the terminal path ends with skillsusa-chapter-media-hub. Check that package.json is saved before trying the installation again.

Need help with the terminal output? Help me troubleshoot my npm install failure.

The temporary page gives Vite a file to serve before the responsive interface is built. Its single line makes the setup result unmistakable in the browser.

  • Select the New File... button in the Explorer.
  • Type index.html as the file name.
  • Press Enter.

The Explorer now lists index.html with the two configuration files.

  • Add the temporary page by pasting this text:
Temporary Vite starter page used only to verify npm run dev at http://localhost:5173. This temporary page is replaced in the next step.

Why Use a Temporary Page?

Vite serves index.html as the browser entry point. This temporary content proves that the project and development server work before the full application shell replaces it.

  • Save index.html by pressing Ctrl+S.

Before you start the server, what do you expect Microsoft Edge to show when Vite reads the temporary index.html file?

  • Start the Vite development server by running this command:
npm run dev

What Does the Development Server Do?

The command runs the dev script from package.json. Vite serves the local index.html file at http://localhost:5173.

The server keeps this terminal occupied while it runs. The Vite terminal shortcut q followed by Enter stops it when you finish working.

PowerShell should print a local address containing http://localhost:5173.

  • Switch back to Microsoft Edge.
  • Enter http://localhost:5173 in the address bar.
  • Press Enter.

You should see Temporary Vite starter page used only to verify npm run dev at http://localhost:5173. This temporary page is replaced in the next step. in Microsoft Edge.

Can’t See the Starter Page?

Keep the Visual Studio Code PowerShell terminal running while you open the local address. Confirm that index.html is saved inside skillsusa-chapter-media-hub.

Still stuck? Help me troubleshoot why my Vite starter page is not loading in Microsoft Edge.

✔️ Awesome, I've got everything!

Your three starter files are saved. The Vite development server is also running from the correct folder.

ⓧ I'd like to double check the full code

  • Compare each saved file with the references below.
{
  "name": "skillsusa-chapter-media-hub",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@supabase/supabase-js": "2.115.0",
    "tus-js-client": "4.3.1"
  },
  "devDependencies": {
    "vite": "8.2.2"
  }
}

This is the complete project manifest for the current step.

VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key

This is the complete environment example for the current step.

Temporary Vite starter page used only to verify npm run dev at http://localhost:5173. This temporary page is replaced in the next step.

This is the complete temporary page for the current step.

Your Windows project is running with its pinned dependencies and starter configuration. Next, you will replace the temporary page with the responsive SkillsUSA application shell.

Build the Responsive App Layout

Your Vite starter is running from the project open in Visual Studio Code. The temporary page proves that Microsoft Edge can reach your local app.

The media hub now needs an early visual destination before backend setup adds complexity. You will replace the starter with a responsive application shell that visitors can browse on wide screens or phones.

In this step, get ready to:
  • Build the semantic application shell for the public feed and protected Admin view.
  • Style the shell for desktop and mobile screens.
  • Render two sample media cards for responsive inspection.
Build the semantic application shell

Semantic HTML gives each part of the page a clear purpose. This shell includes navigation landmarks, a public feed, account controls, an Admin view, and upload placeholders that later steps activate.

  • Select index.html in the Visual Studio Code Explorer sidebar.
  • Replace the temporary starter text with the complete application shell in the reference below.

✔️ Awesome, I've got everything!

Save index.html after checking that the module entry point is /src/main.js.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta
      name="description"
      content="A responsive SkillsUSA chapter media hub for flyers, photos, and videos."
    />
    <title>SkillsUSA Chapter Hub</title>
  </head>
  <body>
    <a class="skip-link" href="#main">Skip to content</a>

    <div class="app-shell">
      <aside class="sidebar" aria-label="Primary navigation">
        <a class="brand" href="#" data-view="feedView" aria-label="SkillsUSA Chapter Hub home">
          <span class="brand-mark" aria-hidden="true">S</span>
          <span>
            <strong>SkillsUSA</strong>
            <small>Chapter Hub</small>
          </span>
        </a>

        <nav class="desktop-nav" aria-label="Main navigation">
          <button class="nav-button is-active" type="button" data-view="feedView">
            Home
          </button>
          <button class="nav-button" type="button" data-view="adminView">
            Admin
          </button>
        </nav>

        <div class="sidebar-note">
          <strong>Chapter media</strong>
          <span>Flyers, photos, and videos in one place.</span>
        </div>
      </aside>

      <div class="app-column">
        <header class="topbar">
          <div>
            <p class="eyebrow">SkillsUSA Chapter</p>
            <h1 id="pageTitle">Media feed</h1>
          </div>
          <span id="accountBadge" class="account-badge">Visitor</span>
        </header>

        <main id="main" tabindex="-1">
          <section id="feedView" class="view" aria-labelledby="feedHeading">
            <div class="hero-card">
              <div>
                <p class="eyebrow">Chapter updates</p>
                <h2 id="feedHeading">See what is happening</h2>
                <p>Browse the newest flyers, event photos, and chapter videos.</p>
              </div>
              <button id="refreshFeedButton" class="secondary-button" type="button">
                Refresh feed
              </button>
            </div>

            <p id="feedStatus" class="status-message" role="status" aria-live="polite">
              Loading published media...
            </p>
            <div id="mediaFeed" class="media-grid" aria-live="polite"></div>
          </section>

          <section id="adminView" class="view" aria-labelledby="adminHeading" hidden>
            <div class="section-heading">
              <p class="eyebrow">Protected workspace</p>
              <h2 id="adminHeading">Admin publishing</h2>
              <p>Sign in to publish chapter media. Account creation does not grant admin access.</p>
            </div>

            <section id="authPanel" class="panel" aria-labelledby="signInHeading">
              <h3 id="signInHeading">Sign in or create an account</h3>
              <form id="authForm" class="stacked-form">
                <label for="email">Email</label>
                <input id="email" name="email" type="email" autocomplete="email" required />

                <label for="password">Password</label>
                <input
                  id="password"
                  name="password"
                  type="password"
                  autocomplete="current-password"
                  minlength="8"
                  required
                />

                <div class="button-row">
                  <button class="primary-button" type="submit">Sign in</button>
                  <button id="signUpButton" class="secondary-button" type="button">
                    Create account
                  </button>
                </div>
              </form>
              <p id="authStatus" class="status-message" role="status" aria-live="polite"></p>
            </section>

            <section id="signedInPanel" class="panel" hidden>
              <div class="signed-in-row">
                <div>
                  <p class="eyebrow">Signed in</p>
                  <strong id="accountEmail"></strong>
                  <code id="accountUid" class="uid"></code>
                </div>
                <button id="signOutButton" class="secondary-button" type="button">
                  Sign out
                </button>
              </div>
              <p id="adminNotice" class="status-message" role="status" aria-live="polite"></p>
            </section>

            <section id="uploaderPanel" class="panel" aria-labelledby="uploadHeading" hidden>
              <h3 id="uploadHeading">Publish media</h3>
              <form id="uploadForm" class="stacked-form">
                <label for="mediaType">Media type</label>
                <select id="mediaType" name="mediaType" required>
                  <option value="flyer">Flyer</option>
                  <option value="photo">Photo</option>
                  <option value="video">Video</option>
                </select>

                <label for="mediaTitle">Title</label>
                <input id="mediaTitle" name="mediaTitle" maxlength="100" required />

                <label for="mediaDescription">Description</label>
                <textarea
                  id="mediaDescription"
                  name="mediaDescription"
                  rows="3"
                  maxlength="300"
                ></textarea>

                <label for="mediaFile">File</label>
                <input
                  id="mediaFile"
                  name="mediaFile"
                  type="file"
                  accept="image/*,video/*,application/pdf"
                  required
                />
                <p class="field-help">Images, PDFs, or videos up to the app's 45 MB safety limit.</p>

                <div id="filePreview" class="file-preview" aria-live="polite">
                  Select a file to preview it here.
                </div>

                <label for="uploadProgress">Upload progress</label>
                <progress id="uploadProgress" max="100" value="0">0%</progress>

                <button id="publishButton" class="primary-button" type="submit">
                  Publish media
                </button>
              </form>
              <p id="uploadStatus" class="status-message" role="status" aria-live="polite"></p>
            </section>
          </section>
        </main>

        <nav class="mobile-nav" aria-label="Mobile navigation">
          <button class="mobile-nav-button is-active" type="button" data-view="feedView">
            Home
          </button>
          <button class="mobile-nav-button" type="button" data-view="adminView">
            Admin
          </button>
        </nav>
      </div>
    </div>

    <script type="module" src="/src/main.js"></script>
  </body>
</html>

How is the shell organized?

  • The #feedView section holds the public feed that every visitor can browse.
  • The #adminView section groups the sign-in panel, account state, and protected uploader.
  • The desktop sidebar and mobile navigation use matching data-view values so one script can switch between views later.
  • The skip link moves keyboard focus directly to #main.
  • Save index.html.
  • Return to Microsoft Edge from earlier.
  • Refresh http://localhost:5173.

You should see the SkillsUSA heading, public feed text, account fields, and navigation controls as an unstyled document. That visible structure confirms that the temporary starter page has been replaced.

Still seeing the temporary page?

  • Confirm that you replaced the contents of index.html inside the skillsusa-chapter-media-hub folder.
  • Confirm that the file tab no longer shows an unsaved-change indicator.
  • Use a hard refresh in Microsoft Edge if the old page remains cached.

Need another pair of eyes? Help me find why my new index.html is not appearing.

Style the desktop and mobile layouts

The shell becomes responsive through CSS. The stylesheet creates a fixed desktop sidebar, a flexible media grid, visible keyboard focus, and controls with a minimum height of 44px.

  • Create a folder named src inside the skillsusa-chapter-media-hub folder using the Visual Studio Code Explorer controls.
  • Create styles.css inside the new src folder using the Explorer file control.

You should now see src/styles.css in the Explorer sidebar. The file path confirms that the stylesheet is in the location used by the module entry point.

  • Add the responsive stylesheet to src/styles.css using the full-file reference below.

✔️ Awesome, I've got everything!

Save src/styles.css after checking that the mobile media query begins at 760px.

ⓧ I'd like to double check the full code

:root {
  color: #172033;
  background: #f4f7fb;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --navy: #14213d;
  --blue: #1f5fae;
  --blue-dark: #174a88;
  --red: #d7263d;
  --surface: #ffffff;
  --surface-soft: #edf3fa;
  --text: #172033;
  --muted: #617087;
  --border: #d9e2ee;
  --shadow: 0 16px 38px rgb(20 33 61 / 10%);
}

* {
  box-sizing: border-box;
}

html {
  min-width: 320px;
  background: #f4f7fb;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input,
select,
textarea {
  font: inherit;
}

button,
a,
input,
select,
textarea {
  min-height: 44px;
}

button {
  cursor: pointer;
}

button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 3px solid #f3b700;
  outline-offset: 3px;
}

[hidden] {
  display: none !important;
}

.skip-link {
  position: fixed;
  top: 0.75rem;
  left: 0.75rem;
  z-index: 100;
  transform: translateY(-180%);
  border-radius: 0.6rem;
  background: #ffffff;
  color: var(--navy);
  padding: 0.75rem 1rem;
  font-weight: 800;
}

.skip-link:focus {
  transform: translateY(0);
}

.app-shell {
  min-height: 100vh;
}

.sidebar {
  position: fixed;
  inset: 0 auto 0 0;
  width: 16rem;
  display: flex;
  flex-direction: column;
  gap: 2rem;
  padding: 1.5rem;
  color: #ffffff;
  background: linear-gradient(170deg, #14213d 0%, #1f5fae 100%);
}

.brand {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  color: #ffffff;
  text-decoration: none;
}

.brand-mark {
  display: grid;
  width: 3rem;
  min-width: 3rem;
  height: 3rem;
  place-items: center;
  border-radius: 0.85rem;
  background: var(--red);
  font-size: 1.4rem;
  font-weight: 900;
}

.brand strong,
.brand small {
  display: block;
}

.brand small {
  margin-top: 0.1rem;
  color: #dbe8ff;
}

.desktop-nav {
  display: grid;
  gap: 0.5rem;
}

.nav-button,
.mobile-nav-button {
  border: 0;
  color: inherit;
  background: transparent;
  font-weight: 750;
}

.nav-button {
  border-radius: 0.75rem;
  padding: 0.8rem 1rem;
  text-align: left;
}

.nav-button:hover,
.nav-button.is-active {
  background: rgb(255 255 255 / 16%);
}

.sidebar-note {
  margin-top: auto;
  display: grid;
  gap: 0.35rem;
  border: 1px solid rgb(255 255 255 / 20%);
  border-radius: 1rem;
  padding: 1rem;
  background: rgb(255 255 255 / 8%);
}

.sidebar-note span {
  color: #dbe8ff;
  font-size: 0.9rem;
  line-height: 1.5;
}

.app-column {
  min-height: 100vh;
  margin-left: 16rem;
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  min-height: 5.5rem;
  border-bottom: 1px solid var(--border);
  padding: 1rem clamp(1rem, 4vw, 3rem);
  background: rgb(255 255 255 / 92%);
  backdrop-filter: blur(14px);
}

.topbar h1,
.hero-card h2,
.section-heading h2,
.panel h3,
.media-card h3 {
  margin: 0;
}

.eyebrow {
  margin: 0 0 0.25rem;
  color: var(--blue);
  font-size: 0.75rem;
  font-weight: 850;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.account-badge,
.media-type-badge {
  display: inline-flex;
  align-items: center;
  border-radius: 999px;
  padding: 0.45rem 0.75rem;
  font-size: 0.8rem;
  font-weight: 800;
}

.account-badge {
  color: var(--blue-dark);
  background: #e8f1fc;
}

main {
  width: min(76rem, 100%);
  margin: 0 auto;
  padding: clamp(1rem, 4vw, 3rem);
}

.view {
  display: grid;
  gap: 1.5rem;
}

.hero-card,
.panel {
  border: 1px solid var(--border);
  border-radius: 1.25rem;
  background: var(--surface);
  box-shadow: var(--shadow);
}

.hero-card {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  padding: clamp(1.25rem, 4vw, 2.25rem);
}

.hero-card p:not(.eyebrow),
.section-heading p:not(.eyebrow) {
  max-width: 42rem;
  margin-bottom: 0;
  color: var(--muted);
  line-height: 1.6;
}

.panel {
  padding: clamp(1rem, 4vw, 1.75rem);
}

.media-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: 1rem;
}

.media-card {
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 1rem;
  background: var(--surface);
  box-shadow: 0 8px 22px rgb(20 33 61 / 8%);
}

.media-preview {
  width: 100%;
  aspect-ratio: 16 / 10;
  display: grid;
  place-items: center;
  overflow: hidden;
  color: var(--blue-dark);
  background: var(--surface-soft);
}

.media-preview img,
.media-preview video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.pdf-preview {
  font-size: 1.05rem;
  font-weight: 850;
}

.media-card-body {
  display: grid;
  gap: 0.75rem;
  padding: 1rem;
}

.media-card-body p {
  margin: 0;
  color: var(--muted);
  line-height: 1.5;
}

.media-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.media-type-badge {
  color: #ffffff;
  background: var(--blue);
  text-transform: capitalize;
}

.media-meta time {
  color: var(--muted);
  font-size: 0.8rem;
}

.media-link {
  width: fit-content;
  color: var(--blue-dark);
  font-weight: 800;
}

.stacked-form {
  display: grid;
  gap: 0.7rem;
  margin-top: 1rem;
}

.stacked-form label {
  font-weight: 800;
}

input,
select,
textarea {
  width: 100%;
  border: 1px solid #b8c5d6;
  border-radius: 0.7rem;
  padding: 0.7rem 0.8rem;
  color: var(--text);
  background: #ffffff;
}

textarea {
  resize: vertical;
}

.button-row,
.signed-in-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.button-row {
  flex-wrap: wrap;
  margin-top: 0.5rem;
}

.signed-in-row {
  justify-content: space-between;
}

.primary-button,
.secondary-button {
  border-radius: 0.7rem;
  padding: 0.7rem 1rem;
  font-weight: 800;
}

.primary-button {
  border: 1px solid var(--blue);
  color: #ffffff;
  background: var(--blue);
}

.primary-button:hover {
  background: var(--blue-dark);
}

.primary-button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.secondary-button {
  border: 1px solid var(--border);
  color: var(--navy);
  background: #ffffff;
}

.secondary-button:hover {
  background: var(--surface-soft);
}

.status-message {
  min-height: 1.5rem;
  margin: 0;
  color: var(--muted);
  line-height: 1.5;
}

.status-message.is-error {
  color: #a2182c;
}

.status-message.is-success {
  color: #17613a;
}

.uid {
  display: block;
  max-width: min(32rem, 75vw);
  margin-top: 0.4rem;
  overflow-wrap: anywhere;
  color: var(--muted);
}

.field-help {
  margin: -0.25rem 0 0;
  color: var(--muted);
  font-size: 0.9rem;
}

.file-preview {
  min-height: 11rem;
  display: grid;
  place-items: center;
  overflow: hidden;
  border: 1px dashed #9db0c8;
  border-radius: 0.8rem;
  padding: 1rem;
  color: var(--muted);
  background: var(--surface-soft);
  text-align: center;
}

.file-preview img,
.file-preview video {
  width: 100%;
  max-height: 20rem;
  object-fit: contain;
}

progress {
  width: 100%;
  height: 1rem;
  accent-color: var(--blue);
}

.empty-state {
  grid-column: 1 / -1;
  border: 1px dashed #9db0c8;
  border-radius: 1rem;
  padding: 2rem;
  color: var(--muted);
  background: var(--surface);
  text-align: center;
}

.mobile-nav {
  display: none;
}

@media (max-width: 760px) {
  .sidebar {
    display: none;
  }

  .app-column {
    margin-left: 0;
    padding-bottom: 5rem;
  }

  .topbar {
    min-height: 4.75rem;
  }

  .topbar h1 {
    font-size: 1.25rem;
  }

  .hero-card,
  .signed-in-row {
    align-items: stretch;
    flex-direction: column;
  }

  .mobile-nav {
    position: fixed;
    inset: auto 0 0;
    z-index: 30;
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    border-top: 1px solid var(--border);
    background: #ffffff;
    box-shadow: 0 -8px 25px rgb(20 33 61 / 10%);
  }

  .mobile-nav-button {
    color: var(--muted);
  }

  .mobile-nav-button.is-active {
    color: var(--blue-dark);
    background: #e8f1fc;
  }
}

What does this stylesheet do?

  • The desktop layout reserves 16rem for the fixed sidebar.
  • The media grid creates as many card columns as the available width can hold.
  • The focus styles give keyboard users a visible yellow outline around interactive controls.
  • The media query hides the sidebar at 760px or narrower. It displays the fixed mobile navigation in its place.
  • Save src/styles.css.
  • Return to the Microsoft Edge tab from earlier.
  • Refresh http://localhost:5173.

You should see a blue desktop sidebar, a white top bar, a raised hero card, and styled form panels. The page should fit within the browser width without horizontal scrolling.

Still seeing an unstyled page?

  • Confirm that the stylesheet is saved at src/styles.css.
  • Confirm that the src folder sits beside index.html.
  • Check the Visual Studio Code terminal for a message mentioning a missing stylesheet.

Still stuck? Help me troubleshoot why styles.css is not loading.

Render sample media cards

Two temporary cards make the responsive grid observable before Supabase supplies real media records. The next step replaces this layout-only script when the public feed connects to the database.

  • Create main.js inside the existing src folder using the Explorer file control.
  • Add the temporary layout script to src/main.js using this code:
import './styles.css'

// Use local examples to test the grid before the live feed is connected
const mediaFeed = document.querySelector('#mediaFeed')
const samples = [
  ['flyer', 'Competition Registration', 'PDF flyer'],
  ['photo', 'Chapter Workshop', 'Photo preview'],
]

// Build each sample with the card classes used by the finished feed
samples.forEach(([type, title, previewText]) => {
  const article = document.createElement('article')
  article.className = 'media-card'
  article.innerHTML = `
    <div class="media-preview ${type === 'flyer' ? 'pdf-preview' : ''}">${previewText}</div>
    <div class="media-card-body">
      <div class="media-meta">
        <span class="media-type-badge">${type}</span>
        <time>Sample</time>
      </div>
      <h3>${title}</h3>
      <p>Responsive sample media for layout inspection.</p>
      <a class="media-link" href="#">${type === 'flyer' ? 'View flyer' : 'Open media'}</a>
    </div>
  `
  mediaFeed.append(article)
})

What does this script do?

  • The stylesheet import connects src/styles.css to the Vite module entry point.
  • The sample list holds one flyer and one photo for layout inspection.
  • The loop creates two .media-card elements inside #mediaFeed.
  • The class names match the finished feed so this visual test exercises the real responsive styles.
  • Save src/main.js.
  • Return to Microsoft Edge from earlier.
  • Refresh http://localhost:5173.

You should see a Competition Registration flyer card beside a Chapter Workshop photo card when enough width is available. Each card has a blue media-type badge, preview area, title, description, and media link.

Sample cards missing?

  • Confirm that src/main.js is saved inside the src folder.
  • Confirm that index.html loads /src/main.js as its module entry point.
  • Confirm that the HTML still contains an element with the mediaFeed ID.

Need help finding the mismatch? Help me debug why the sample media cards are missing.

Before you test the responsive layout, which navigation do you expect to remain visible when the browser becomes narrower than the mobile breakpoint?

  • Return to the Visual Studio Code PowerShell terminal from earlier.
  • Confirm that npm run dev is still serving the local project.
  • Return to http://localhost:5173 in Microsoft Edge.
  • Widen the Microsoft Edge window until the desktop layout fills the screen.

You should see the fixed sidebar on the left. The two sample cards should sit in the responsive grid without horizontal page scrolling.

  • Narrow the Microsoft Edge window until the layout switches below 760px.
  • Try to scroll the page horizontally.

You should see the sidebar disappear and the Home and Admin navigation stay fixed along the bottom. The cards should stack within the viewport without horizontal page movement.

That is your first complete app view working across desktop and mobile widths. Next, you will replace the sample cards with a public feed and test the boundary between signing in and receiving admin access.

Connect the Feed and Test Access

Your responsive application shell now gives visitors a clear feed on desktop screens and mobile screens. The sample cards prove the layout works, but their content disappears as soon as you remove it from the script.

This step connects the feed to Supabase and stores media metadata in Postgres. You will also use Row Level Security to prove that a signed-in account does not automatically receive publishing authority.

In this step, get ready to:
  • Create the media tables, public Storage bucket, grants, and read policies.
  • Connect the browser app to your Supabase project.
  • Create an account and test the deliberate permission boundary.
Create the Supabase data foundation

The public feed needs permanent metadata for each published file. The database also needs a separate membership table so publishing access can be granted explicitly.

Why Supabase for this project?

Supabase keeps authentication, Postgres metadata, file storage, and access policies in one project. This gives the browser one backend while the database remains responsible for authorization.

The public bucket serves media that has already been published. Upload and deletion operations still depend on Storage policies.

  • Return to the Supabase Dashboard in Microsoft Edge.
  • Sign in to your Supabase account.
  • Select the project that will host your SkillsUSA media hub.
  • Return to Visual Studio Code.
  • Create a folder named supabase from the Explorer sidebar.
  • Create 01-base.sql inside the supabase folder.
  • Add the table definitions and least-privilege grants to supabase/01-base.sql by copying this code:
create table if not exists public.admin_users (
  user_id uuid primary key references auth.users (id) on delete cascade
);

create table if not exists public.media_items (
  id uuid primary key default gen_random_uuid(),
  title text not null check (char_length(title) between 1 and 100),
  description text not null default '' check (char_length(description) <= 300),
  media_type text not null check (media_type in ('flyer', 'photo', 'video')),
  mime_type text not null,
  file_path text not null unique,
  is_published boolean not null default true,
  created_by uuid not null default auth.uid() references auth.users (id),
  created_at timestamptz not null default now()
);

alter table public.admin_users enable row level security;
alter table public.media_items enable row level security;

revoke all on table public.admin_users from anon, authenticated;
revoke all on table public.media_items from anon, authenticated;

grant select on table public.admin_users to authenticated;
grant select on table public.media_items to anon, authenticated;
grant insert on table public.media_items to authenticated;

What does this SQL create?

  • The admin_users table stores explicit publishing membership for each user ID.
  • The media_items table stores titles, descriptions, media types, file paths, publishing state, ownership, and creation times.
  • Row Level Security enables database policies on both tables.
  • The grants allow public feed reads while withholding unnecessary table operations.
  • Save supabase/01-base.sql.
  • Add the membership policy, public-feed policy, and Storage bucket below the existing SQL:
drop policy if exists "Users can view own admin membership" on public.admin_users;
create policy "Users can view own admin membership"
on public.admin_users for select
to authenticated
using ((select auth.uid()) = user_id);

drop policy if exists "Anyone can view published media" on public.media_items;
create policy "Anyone can view published media"
on public.media_items for select
to anon, authenticated
using (is_published = true);

insert into storage.buckets (id, name, public)
values ('chapter-media', 'chapter-media', true)
on conflict (id) do update set public = excluded.public;

How do the policies shape access?

Authenticated users can read only the membership row matching their own user ID. Visitors can read media records only when is_published is true.

The chapter-media bucket is public because these files are intended for visitors. No upload policy exists yet, so this step deliberately stops short of publishing access.

  • Save supabase/01-base.sql.
  • Switch back to the Supabase Dashboard.
  • Open the SQL Editor for your project.
  • Create a new query in the SQL Editor.
  • Copy the complete contents of supabase/01-base.sql into the query.
  • Run the query.

You should see a success confirmation from the SQL Editor. That is the data foundation in place: the public feed can read published metadata, while no account can upload yet.

Did the SQL query fail?

  • Check that both table definitions appear before the policies that reference them.
  • Confirm that every SQL statement ends with a full stop character used by SQL.
  • Rerun the complete file so earlier statements can create any missing resources.

Need help reading the database error? Help me troubleshoot the base Supabase SQL.

✔️ The SQL query succeeded

Your tables, grants, read policies, and public Storage bucket are ready. Keep the successful query available while you connect the browser app.

ⓧ I'd like to double check the full SQL

create table if not exists public.admin_users (
  user_id uuid primary key references auth.users (id) on delete cascade
);

create table if not exists public.media_items (
  id uuid primary key default gen_random_uuid(),
  title text not null check (char_length(title) between 1 and 100),
  description text not null default '' check (char_length(description) <= 300),
  media_type text not null check (media_type in ('flyer', 'photo', 'video')),
  mime_type text not null,
  file_path text not null unique,
  is_published boolean not null default true,
  created_by uuid not null default auth.uid() references auth.users (id),
  created_at timestamptz not null default now()
);

alter table public.admin_users enable row level security;
alter table public.media_items enable row level security;

revoke all on table public.admin_users from anon, authenticated;
revoke all on table public.media_items from anon, authenticated;

grant select on table public.admin_users to authenticated;
grant select on table public.media_items to anon, authenticated;
grant insert on table public.media_items to authenticated;

drop policy if exists "Users can view own admin membership" on public.admin_users;
create policy "Users can view own admin membership"
on public.admin_users for select
to authenticated
using ((select auth.uid()) = user_id);

drop policy if exists "Anyone can view published media" on public.media_items;
create policy "Anyone can view published media"
on public.media_items for select
to anon, authenticated
using (is_published = true);

insert into storage.buckets (id, name, public)
values ('chapter-media', 'chapter-media', true)
on conflict (id) do update set public = excluded.public;
Connect the browser app

The browser needs your project URL so requests reach the correct Supabase project. It also needs the publishable key so Supabase can apply the visitor or signed-in role to each request.

  • Return to Visual Studio Code.
  • Duplicate .env.example from the Explorer sidebar.
  • Rename the duplicate file to .env.
  • Switch back to the Supabase Dashboard.
  • Open the project's Connect dialog.
  • Copy the project URL from the Connect dialog.
  • Return to .env in Visual Studio Code.
  • Replace https://your-project-ref.supabase.co with the copied project URL.

Why use environment variables?

Vite exposes values beginning with VITE_ to browser code through import.meta.env. Your local .env file keeps project-specific configuration separate from the shared source files.

A Supabase publishable key is designed for browser applications. A secret key must never be placed in this file or shipped to the browser.

  • Switch back to the Connect dialog.
  • Copy the publishable key.
  • Return to .env in Visual Studio Code.
  • Replace your-publishable-key with the copied publishable key.
  • Save .env.
  • Return to the Supabase Dashboard.
  • Open the project's URL Configuration page.
  • Add http://localhost:5173 to the Redirect URLs list.

The redirect entry allows an email confirmation to return to your locally running app. The URL must match the address served by Vite.

  • Return to Visual Studio Code.
  • Create src/supabase.js from the Explorer sidebar.
  • Add the Supabase client configuration to src/supabase.js by copying this code:
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY

if (!supabaseUrl || !supabasePublishableKey) {
  throw new Error('Add VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY to .env')
}

export const supabase = createClient(supabaseUrl, supabasePublishableKey)
export const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
export const mediaBucket = 'chapter-media'

What does this client configuration do?

  • The supabaseUrl and supabasePublishableKey constants read your local Vite configuration.
  • The missing-value check stops the app with a clear configuration message.
  • The supabase client handles database, authentication, and Storage requests.
  • The projectRef and mediaBucket exports prepare the direct Storage upload path used by the application.
  • Save src/supabase.js.

✔️ I saved the Supabase client

Your browser code now has one shared client for database, Auth, and Storage requests.

ⓧ I'd like to double check the full code

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY

if (!supabaseUrl || !supabasePublishableKey) {
  throw new Error('Add VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY to .env')
}

export const supabase = createClient(supabaseUrl, supabasePublishableKey)
export const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
export const mediaBucket = 'chapter-media'
  • Stop the running Vite process by pressing Ctrl+C in the Visual Studio Code PowerShell terminal.
  • Restart Vite with the new environment values by running:
npm run dev

PowerShell should print the local Vite address again. Keep this process running while you connect the feed.

Does Vite report missing Supabase values?

  • Confirm the local file is named exactly .env.
  • Check that both lines contain values after their equals signs.
  • Restart Vite after every change to .env.

Still blocked? Help me check my Vite environment configuration.

Load the feed and test the permission wall

The temporary cards have finished their job. The application script can now replace them with records loaded from media_items and show account state from Supabase Auth.

The script also checks admin_users after sign-in. An empty result keeps the uploader hidden, which makes the authorization boundary visible inside the app.

  • Select src/main.js in the Visual Studio Code Explorer sidebar.
  • Remove the temporary layout-only script.
  • Replace it with the complete application script shown in the double-check tab below.

✔️ I replaced the temporary script

Save src/main.js after checking that the import paths and element selectors match the complete script.

ⓧ I'd like to double check the full code

import './styles.css'
import * as tus from 'tus-js-client'
import { mediaBucket, projectRef, supabase } from './supabase.js'

const maxFileBytes = 45 * 1024 * 1024
const allowedMimeTypes = new Set([
  'application/pdf',
  'image/gif',
  'image/jpeg',
  'image/png',
  'image/webp',
  'video/mp4',
  'video/quicktime',
  'video/webm',
])

const pageTitle = document.querySelector('#pageTitle')
const accountBadge = document.querySelector('#accountBadge')
const feedStatus = document.querySelector('#feedStatus')
const mediaFeed = document.querySelector('#mediaFeed')
const refreshFeedButton = document.querySelector('#refreshFeedButton')
const authPanel = document.querySelector('#authPanel')
const authForm = document.querySelector('#authForm')
const signUpButton = document.querySelector('#signUpButton')
const authStatus = document.querySelector('#authStatus')
const signedInPanel = document.querySelector('#signedInPanel')
const accountEmail = document.querySelector('#accountEmail')
const accountUid = document.querySelector('#accountUid')
const adminNotice = document.querySelector('#adminNotice')
const signOutButton = document.querySelector('#signOutButton')
const uploaderPanel = document.querySelector('#uploaderPanel')
const uploadForm = document.querySelector('#uploadForm')
const mediaType = document.querySelector('#mediaType')
const mediaFile = document.querySelector('#mediaFile')
const filePreview = document.querySelector('#filePreview')
const uploadProgress = document.querySelector('#uploadProgress')
const uploadStatus = document.querySelector('#uploadStatus')
const publishButton = document.querySelector('#publishButton')

let currentSession = null
let previewUrl = null

function setStatus(element, message, tone = '') {
  element.textContent = message
  element.classList.toggle('is-error', tone === 'error')
  element.classList.toggle('is-success', tone === 'success')
}

function showView(viewId) {
  document.querySelectorAll('.view').forEach((view) => {
    view.hidden = view.id !== viewId
  })

  document.querySelectorAll('[data-view]').forEach((button) => {
    const isActive = button.dataset.view === viewId
    button.classList.toggle('is-active', isActive)
    button.setAttribute('aria-current', isActive ? 'page' : 'false')
  })

  pageTitle.textContent = viewId === 'adminView' ? 'Admin publishing' : 'Media feed'
  document.querySelector('#main').focus()
}

function makeElement(tag, className, text) {
  const element = document.createElement(tag)
  if (className) element.className = className
  if (text) element.textContent = text
  return element
}

function createMediaCard(item) {
  const article = makeElement('article', 'media-card')
  const preview = makeElement('div', 'media-preview')
  const { data } = supabase.storage.from(mediaBucket).getPublicUrl(item.file_path)
  const publicUrl = data.publicUrl

  if (item.media_type === 'video') {
    const video = document.createElement('video')
    video.src = publicUrl
    video.controls = true
    video.preload = 'metadata'
    video.setAttribute('aria-label', item.title)
    preview.append(video)
  } else if (item.mime_type.startsWith('image/')) {
    const image = document.createElement('img')
    image.src = publicUrl
    image.alt = item.title
    image.loading = 'lazy'
    preview.append(image)
  } else {
    preview.append(makeElement('span', 'pdf-preview', 'PDF flyer'))
  }

  const body = makeElement('div', 'media-card-body')
  const meta = makeElement('div', 'media-meta')
  meta.append(
    makeElement('span', 'media-type-badge', item.media_type),
    makeElement(
      'time',
      '',
      new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(
        new Date(item.created_at),
      ),
    ),
  )

  body.append(meta, makeElement('h3', '', item.title))
  if (item.description) body.append(makeElement('p', '', item.description))

  const link = makeElement(
    'a',
    'media-link',
    item.media_type === 'flyer' ? 'View flyer' : 'Open media',
  )
  link.href = publicUrl
  link.target = '_blank'
  link.rel = 'noopener'
  body.append(link)

  article.append(preview, body)
  return article
}

async function loadMedia() {
  setStatus(feedStatus, 'Loading published media...')

  const { data, error } = await supabase
    .from('media_items')
    .select('id,title,description,media_type,mime_type,file_path,created_at')
    .eq('is_published', true)
    .order('created_at', { ascending: false })

  if (error) {
    mediaFeed.replaceChildren()
    setStatus(feedStatus, error.message, 'error')
    return
  }

  mediaFeed.replaceChildren()

  if (!data.length) {
    mediaFeed.append(
      makeElement('div', 'empty-state', 'No media has been published yet.'),
    )
    setStatus(feedStatus, 'The feed is connected and ready for its first upload.')
    return
  }

  data.forEach((item) => mediaFeed.append(createMediaCard(item)))
  setStatus(feedStatus, `${data.length} published item${data.length === 1 ? '' : 's'}.`)
}

async function refreshAuthUi(sessionOverride) {
  if (sessionOverride === undefined) {
    const { data } = await supabase.auth.getSession()
    currentSession = data.session
  } else {
    currentSession = sessionOverride
  }

  if (!currentSession) {
    authPanel.hidden = false
    signedInPanel.hidden = true
    uploaderPanel.hidden = true
    accountBadge.textContent = 'Visitor'
    setStatus(adminNotice, '')
    return
  }

  authPanel.hidden = true
  signedInPanel.hidden = false
  accountEmail.textContent = currentSession.user.email ?? 'Signed-in account'
  accountUid.textContent = currentSession.user.id
  accountBadge.textContent = 'Signed in'

  const { data, error } = await supabase
    .from('admin_users')
    .select('user_id')
    .eq('user_id', currentSession.user.id)
    .maybeSingle()

  const isAdmin = Boolean(data) && !error
  uploaderPanel.hidden = !isAdmin

  if (isAdmin) {
    accountBadge.textContent = 'Admin'
    setStatus(adminNotice, 'Admin access verified.', 'success')
  } else {
    setStatus(adminNotice, 'Signed in, but this account is not an admin.', 'error')
  }
}

async function signIn(event) {
  event.preventDefault()
  setStatus(authStatus, 'Signing in...')

  const formData = new FormData(authForm)
  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (error) {
    setStatus(authStatus, error.message, 'error')
    return
  }

  setStatus(authStatus, 'Signed in.', 'success')
  await refreshAuthUi()
}

async function signUp() {
  const formData = new FormData(authForm)
  const email = formData.get('email')
  const password = formData.get('password')

  if (!email || !password) {
    setStatus(authStatus, 'Enter an email and password first.', 'error')
    return
  }

  setStatus(authStatus, 'Creating account...')
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: window.location.origin,
    },
  })

  if (error) {
    setStatus(authStatus, error.message, 'error')
    return
  }

  if (data.session) {
    setStatus(authStatus, 'Account created and signed in.', 'success')
    await refreshAuthUi(data.session)
  } else {
    setStatus(authStatus, 'Account created. Confirm the email, then sign in.', 'success')
  }
}

function updateFilePreview() {
  if (previewUrl) URL.revokeObjectURL(previewUrl)
  filePreview.replaceChildren()

  const file = mediaFile.files[0]
  if (!file) {
    filePreview.textContent = 'Select a file to preview it here.'
    return
  }

  if (!allowedMimeTypes.has(file.type)) {
    filePreview.textContent = 'This file type is not supported.'
    return
  }

  previewUrl = URL.createObjectURL(file)

  if (file.type.startsWith('image/')) {
    const image = document.createElement('img')
    image.src = previewUrl
    image.alt = 'Selected upload preview'
    filePreview.append(image)
  } else if (file.type.startsWith('video/')) {
    const video = document.createElement('video')
    video.src = previewUrl
    video.controls = true
    video.preload = 'metadata'
    filePreview.append(video)
  } else {
    filePreview.textContent = `PDF ready: ${file.name}`
  }
}

function fileMatchesMediaType(file, selectedType) {
  if (selectedType === 'photo') return file.type.startsWith('image/')
  if (selectedType === 'video') return file.type.startsWith('video/')
  return file.type.startsWith('image/') || file.type === 'application/pdf'
}

function makeSafeFileName(fileName) {
  return fileName
    .toLowerCase()
    .replace(/[^a-z0-9._-]+/g, '-')
    .replace(/-+/g, '-')
}

function uploadToStorage(file, filePath, session) {
  return new Promise((resolve, reject) => {
    const upload = new tus.Upload(file, {
      endpoint: `https://${projectRef}.storage.supabase.co/storage/v1/upload/resumable`,
      retryDelays: [0, 3000, 5000, 10000, 20000],
      headers: {
        authorization: `Bearer ${session.access_token}`,
      },
      uploadDataDuringCreation: true,
      removeFingerprintOnSuccess: true,
      metadata: {
        bucketName: mediaBucket,
        objectName: filePath,
        contentType: file.type,
        cacheControl: '3600',
      },
      chunkSize: 6 * 1024 * 1024,
      onError(error) {
        reject(error)
      },
      onProgress(bytesUploaded, bytesTotal) {
        const percentage = Math.round((bytesUploaded / bytesTotal) * 100)
        uploadProgress.value = percentage
        uploadProgress.textContent = `${percentage}%`
        setStatus(uploadStatus, `Uploading ${percentage}%...`)
      },
      onSuccess() {
        resolve()
      },
    })

    upload
      .findPreviousUploads()
      .then((previousUploads) => {
        if (previousUploads.length) {
          upload.resumeFromPreviousUpload(previousUploads[0])
        }
        upload.start()
      })
      .catch(reject)
  })
}

async function publishMedia(event) {
  event.preventDefault()

  const file = mediaFile.files[0]
  const selectedType = mediaType.value

  if (!currentSession) {
    setStatus(uploadStatus, 'Sign in before uploading.', 'error')
    return
  }

  if (!file || !allowedMimeTypes.has(file.type)) {
    setStatus(uploadStatus, 'Choose a supported image, PDF, or video.', 'error')
    return
  }

  if (!fileMatchesMediaType(file, selectedType)) {
    setStatus(uploadStatus, 'The selected file does not match the media type.', 'error')
    return
  }

  if (file.size > maxFileBytes) {
    setStatus(uploadStatus, 'Choose a file smaller than 45 MB.', 'error')
    return
  }

  publishButton.disabled = true
  uploadProgress.value = 0
  setStatus(uploadStatus, 'Preparing resumable upload...')

  const safeName = makeSafeFileName(file.name)
  const filePath = `${currentSession.user.id}/${Date.now()}-${safeName}`
  const formData = new FormData(uploadForm)

  try {
    await uploadToStorage(file, filePath, currentSession)

    const { error: insertError } = await supabase.from('media_items').insert({
      title: formData.get('mediaTitle'),
      description: formData.get('mediaDescription') ?? '',
      media_type: selectedType,
      mime_type: file.type,
      file_path: filePath,
    })

    if (insertError) {
      await supabase.storage.from(mediaBucket).remove([filePath])
      throw insertError
    }

    uploadProgress.value = 100
    setStatus(uploadStatus, 'Published successfully.', 'success')
    uploadForm.reset()
    updateFilePreview()
    await loadMedia()
  } catch (error) {
    setStatus(uploadStatus, error.message ?? 'Upload failed.', 'error')
  } finally {
    publishButton.disabled = false
  }
}

document.querySelectorAll('[data-view]').forEach((button) => {
  button.addEventListener('click', (event) => {
    event.preventDefault()
    showView(button.dataset.view)
  })
})

refreshFeedButton.addEventListener('click', loadMedia)
authForm.addEventListener('submit', signIn)
signUpButton.addEventListener('click', signUp)
mediaFile.addEventListener('change', updateFilePreview)
uploadForm.addEventListener('submit', publishMedia)

signOutButton.addEventListener('click', async () => {
  await supabase.auth.signOut({ scope: 'local' })
  await refreshAuthUi(null)
  showView('feedView')
})

supabase.auth.onAuthStateChange((_event, session) => {
  window.setTimeout(() => refreshAuthUi(session), 0)
})

showView('feedView')
await Promise.all([loadMedia(), refreshAuthUi()])

What does the application script handle?

  • The feed query selects published records from media_items and renders the correct image, PDF, or video card.
  • The Auth handlers create accounts, sign users in, sign the current browser session out, and refresh the visible account state.
  • The membership query looks for the signed-in user ID inside admin_users.
  • The upload handlers remain protected behind the hidden uploader while the membership query returns no admin row.
  • Save src/main.js.
  • Switch back to the locally running app in Microsoft Edge.
  • Refresh http://localhost:5173.

You should see the empty media state instead of the two sample cards. The feed status reads The feed is connected and ready for its first upload. This confirms that the public read policy is working.

Does the feed show an error?

  • Confirm that Vite was restarted after you saved .env.
  • Check that the project URL begins with https://.
  • Confirm that supabase/01-base.sql completed successfully in the SQL Editor.

Need help tracing the request? Help me diagnose the public feed connection.

The public connection works. The next check creates a real identity and tests whether authentication alone unlocks the protected publishing workspace.

  • Select Admin in the app navigation.
  • Enter your email address in the Email field.
  • Enter a password with at least eight characters in the Password field.
  • Select Create account.

Email delivery can take a few moments, so a short wait here is normal. You should see a message asking you to confirm the account before signing in.

  • Open the confirmation email in your email service.
  • Use the confirmation link in the email.
  • Return to the locally running app.
  • Select Admin if the confirmation returned you to the public feed.
  • Enter your email address in the Email field.
  • Enter the same password in the Password field.

Before you sign in, do you think a confirmed account automatically receives publishing access?

  • Select Sign in.

You should see your email address and user ID in the signed-in panel. The Admin view displays Signed in, but this account is not an admin.

That denial is the intended result. Your account is authenticated, but the empty admin_users table gives it no publishing membership.

Authentication and authorization play different roles

Authentication proves which account is making a request. Authorization decides what that account can do.

The visible denial proves your app keeps those decisions separate. The uploader remains hidden because the membership query found no matching admin row.

Unable to confirm or sign in?

  • Confirm that http://localhost:5173 appears in the Redirect URLs list on the URL Configuration page.
  • Check the inbox associated with the email address entered in the app.
  • Use the same email address and password when you return to the sign-in form.

Still unable to reach the signed-in panel? Help me troubleshoot the Supabase email confirmation flow.

Your feed now reads from Supabase, and your first account has reached the permission wall exactly as designed. Next, you will grant explicit admin membership and let Row Level Security unlock the uploader.

Grant Secure Admin Access

Your public feed now loads from Supabase. Your first account can sign in.

The deliberate denial proved that authentication only identifies the account. This step adds explicit membership so Row Level Security can enforce every publishing request.

In this step, get ready to:
  • Create a protected helper that checks admin membership.
  • Protect metadata writes plus file operations with authorization policies.
  • Promote your first account and verify the protected uploader.
Create the admin helper

The private.is_admin() helper centralizes the membership check. Every publishing policy can ask this helper whether the current auth.uid() has a row in public.admin_users.

  • Return to the Admin view in the Vite app from earlier.
  • Copy the UUID shown beneath your signed-in email into first confirmed Auth user UUID.
  • Switch back to Visual Studio Code.
  • Create 02-admin-access.sql inside the existing supabase folder using the Explorer sidebar.
  • Add the protected helper to supabase/02-admin-access.sql by pasting this code:
create schema if not exists private;

create or replace function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1
    from public.admin_users
    where user_id = (select auth.uid())
  );
$$;

revoke all on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

What Does This Helper Do?

  • The private.is_admin() function looks for the current user's UUID in public.admin_users.
  • The security definer setting lets the protected helper perform its membership lookup.
  • The empty search_path = '' setting requires schema-qualified names inside the function.
  • The grants let authenticated requests call the helper without exposing broader access.
  • Save supabase/02-admin-access.sql.
  • Copy the helper code you just added.
  • Return to the Supabase SQL Editor from earlier.
  • Replace the current query with the copied helper code.
  • Run the query.

You should see a successful query result with no SQL error. The private helper is now available to later policies.

Helper Query Failed?

  • Confirm that supabase/01-base.sql completed before this query.
  • Check that the table name is exactly public.admin_users.
  • Copy the entire function through the final grant statement before running it again.

Still blocked? Help me troubleshoot the private.is_admin() SQL helper.

Protect metadata and Storage

Authorization stays inside Postgres plus Supabase Storage. Your browser continues using only the publishable key.

The metadata policy checks whether the user is an admin. It also requires created_by to match the active auth.uid().

  • Append the media metadata policy to supabase/02-admin-access.sql by pasting this code:
drop policy if exists "Admins can insert media metadata" on public.media_items;
create policy "Admins can insert media metadata"
on public.media_items for insert
to authenticated
with check (
  (select private.is_admin())
  and created_by = (select auth.uid())
);

How Is Metadata Protected?

  • The with check clause validates every attempted insert.
  • The first condition requires a matching admin membership.
  • The second condition prevents one admin from claiming another user's UUID as created_by.
  • Save supabase/02-admin-access.sql.
  • Copy the metadata policy you just added.
  • Return to the Supabase SQL Editor.
  • Replace the current query with the copied metadata policy.
  • Run the query.

You should see a successful query result. Authenticated users now need admin membership before inserting media metadata.

Metadata Policy Failed?

  • Confirm that the private.is_admin() helper query succeeded first.
  • Check that the policy targets public.media_items.
  • Verify that both conditions remain inside the with check clause.

Need another pair of eyes? Help me debug the media metadata policy.

File authorization needs one more boundary. Each upload path must begin with the signed-in user's UUID before Storage accepts it.

  • Append the Storage insert policy to supabase/02-admin-access.sql by pasting this code:
drop policy if exists "Admins can upload chapter media" on storage.objects;
create policy "Admins can upload chapter media"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'chapter-media'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
  and (select private.is_admin())
);

How Are Upload Paths Protected?

  • The bucket condition limits this policy to chapter-media.
  • The folder condition requires the first path segment to match the signed-in user's UUID.
  • The final condition allows the upload only when private.is_admin() returns true.
  • Save supabase/02-admin-access.sql.
  • Copy the Storage insert policy.
  • Return to the Supabase SQL Editor.
  • Replace the current query with the copied Storage insert policy.
  • Run the query.

You should see a successful query result. Storage now checks the bucket name plus the user folder before accepting an upload.

Upload Policy Failed?

  • Check that the policy targets storage.objects.
  • Confirm that the bucket value is exactly chapter-media.
  • Verify that storage.foldername(name) keeps its parentheses plus first-folder index.

Still seeing a SQL error? Help me troubleshoot the chapter-media upload policy.

Admins also need to inspect stored objects. This access supports resumable upload checks.

  • Append the Storage select policy to supabase/02-admin-access.sql by pasting this code:
drop policy if exists "Admins can inspect chapter media" on storage.objects;
create policy "Admins can inspect chapter media"
on storage.objects for select
to authenticated
using (
  bucket_id = 'chapter-media'
  and (select private.is_admin())
);

Why Allow Object Inspection?

This policy lets authenticated admins inspect objects in chapter-media. Ordinary authenticated accounts still fail the admin check.

  • Save supabase/02-admin-access.sql.
  • Copy the Storage select policy.
  • Return to the Supabase SQL Editor.
  • Replace the current query with the copied Storage select policy.
  • Run the query.

You should see a successful query result. Admins can now inspect objects stored in the chapter media bucket.

Inspection Policy Failed?

  • Confirm that this policy uses for select.
  • Check that the access condition sits inside using.
  • Verify that the admin helper includes the private schema name.

Need help tracing the failure? Help me debug the Storage select policy.

A failed metadata insert must not leave an unwanted file behind. The delete policy gives admins permission to remove that failed upload.

  • Append the Storage delete policy to supabase/02-admin-access.sql by pasting this code:
drop policy if exists "Admins can remove failed chapter media" on storage.objects;
create policy "Admins can remove failed chapter media"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'chapter-media'
  and (select private.is_admin())
);

Why Allow Failed Upload Cleanup?

The app removes a stored object when its metadata insert fails. This policy limits that cleanup permission to admins working with chapter-media.

  • Save supabase/02-admin-access.sql.
  • Copy the Storage delete policy.
  • Return to the Supabase SQL Editor.
  • Replace the current query with the copied Storage delete policy.
  • Run the query.

You should see a successful query result. Admins can now remove an uploaded object when publishing cannot finish.

Delete Policy Failed?

  • Confirm that this policy uses for delete.
  • Check that the policy targets only chapter-media.
  • Verify that the condition calls private.is_admin().

Still stuck? Help me troubleshoot the Storage delete policy.

Promote your first account

The policies are active now. Your account still needs one explicit membership row before those policies recognize it as an admin.

  • Return to supabase/02-admin-access.sql in Visual Studio Code.
  • Find the commented membership statement at the bottom of the file.
  • Use these lines as your reference:
-- insert into public.admin_users (user_id)
-- values ('your-user-id-here');
  • Remove the comment markers from both lines.
  • Replace your-user-id-here with first confirmed Auth user UUID.
  • Confirm that the statement now matches this pattern:
insert into public.admin_users (user_id)
values ('[[FIRST_AUTH_USER_UUID="first confirmed Auth user UUID"]]');

What Changes With This Row?

The row creates explicit membership for your first confirmed account. Future calls to private.is_admin() can now return true for that user's authenticated requests.

✔️ Awesome, I've got everything!

Great. Double-check that you saved supabase/02-admin-access.sql with your own user UUID in the final statement.

ⓧ I'd like to double check the full code

create schema if not exists private;

create or replace function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1
    from public.admin_users
    where user_id = (select auth.uid())
  );
$$;

revoke all on function private.is_admin() from public;
grant usage on schema private to authenticated;
grant execute on function private.is_admin() to authenticated;

drop policy if exists "Admins can insert media metadata" on public.media_items;
create policy "Admins can insert media metadata"
on public.media_items for insert
to authenticated
with check (
  (select private.is_admin())
  and created_by = (select auth.uid())
);

drop policy if exists "Admins can upload chapter media" on storage.objects;
create policy "Admins can upload chapter media"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'chapter-media'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
  and (select private.is_admin())
);

drop policy if exists "Admins can inspect chapter media" on storage.objects;
create policy "Admins can inspect chapter media"
on storage.objects for select
to authenticated
using (
  bucket_id = 'chapter-media'
  and (select private.is_admin())
);

drop policy if exists "Admins can remove failed chapter media" on storage.objects;
create policy "Admins can remove failed chapter media"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'chapter-media'
  and (select private.is_admin())
);

insert into public.admin_users (user_id)
values ('[[FIRST_AUTH_USER_UUID="first confirmed Auth user UUID"]]');
  • Save supabase/02-admin-access.sql.
  • Copy the complete file contents.
  • Return to the Supabase SQL Editor.
  • Replace the current query with the complete file contents.
  • Run the complete query.

You should see a successful query result with no SQL error. Your user's UUID now exists in public.admin_users.

Membership Insert Failed?

  • Confirm that you replaced the placeholder with the complete UUID shown by the app.
  • Check that the UUID remains inside single quotes.
  • Verify that the account still exists in your Supabase project.

Need help checking the final statement? Help me troubleshoot the admin membership insert.

Before you refresh the session, do you expect the Admin view to keep showing the denial or reveal the uploader?

  • Return to the Admin view in the Vite app.
  • Select Sign out.
  • Enter the email address for your promoted account.
  • Enter the password for your promoted account.
  • Select Sign in.

You should see Admin in the account badge. The notice should display Admin access verified..

The protected upload form should also be visible. This proves that explicit membership changed the account's authorized capabilities.

That security boundary is now working. Your first admin can publish only because the database recognizes its explicit membership.

Your protected workspace is ready. Next, you will publish a flyer, photo, or video with resumable upload progress.

Publish Media

Your explicit admin membership now passes Supabase's Row Level Security checks. The protected uploader is ready for the chapter's first published item.

The app uses tus-js-client for resumable uploads with visible progress. A signed-out feed check proves visitors can consume the result.

In this step, get ready to:
  • Preview a supported flyer, photo, or video.
  • Publish the file through the resumable upload workflow.
  • Verify that signed-out visitors can view the published media.
Preview a media file

The preview area checks whether the browser can display your selection. It also helps you catch a mismatched media type before uploading.

  • Switch back to the Microsoft Edge window from earlier.
  • Select Flyer, Photo, or Video in the Media type field.
  • Enter a clear media title in the Title field.
  • Enter an optional summary in the Description field.
  • Choose an allowed file smaller than 45 MB in the File field.

You'll see the preview area update as soon as the browser accepts the file. This gives you a final visual check before the upload begins.

What Should the Preview Show?

  • An image flyer or photo appears directly inside the preview area.
  • A PDF flyer displays its filename as ready for upload.
  • A video appears with playback controls for a quick review.
  • The selected file must match the media type chosen in the form.

Preview Not Updating?

  • Confirm that the selected media type matches the file.
  • Choose a supported image, PDF, or video file.
  • Choose a smaller file if its size exceeds 45 MB.

Still stuck? Help me diagnose why my selected media file is not appearing in filePreview.

Publish with resumable progress

Resumable uploads keep progress visible during larger transfers. The media record is created only after the file reaches Supabase Storage.

Before you publish, which signal do you expect to appear first: upload progress or the new feed card?

  • Click Publish media.
  • Watch the Upload progress bar move toward 100%.

You'll see Published successfully. after the file reaches Storage and its media record is created. The progress bar remains at 100%.

You have completed the core publishing path with a stored file linked to its feed record.

What Happens During Publishing?

  • The uploadToStorage() function sends the file in 6 MB chunks to the project's direct Supabase Storage hostname.
  • The current session's access token lets Storage apply the admin and user-folder policies.
  • The retryDelays setting schedules five attempts after an interruption.
  • A matching interrupted upload resumes from its previous upload record.
  • The publishMedia() function inserts the title, description, media type, MIME type, and file path after Storage succeeds.
  • A failed metadata insert removes the uploaded object so the bucket does not keep an unlisted file.

Upload Not Completing?

  • Confirm that the account badge still reads Admin.
  • Confirm that the selected media type matches the file.
  • Choose a file smaller than 45 MB.
  • Allow the configured retries to finish after a brief network interruption.

Need help? Help me troubleshoot why my resumable media upload did not complete.

Prove public access after sign-out

The final check separates publishing authority from public viewing. Your admin session can end without removing already published media.

Before you sign out, do you expect the uploaded card to disappear with your admin session or remain in the feed?

  • Click Sign out in the signed-in panel.

You'll return to the Home view with the account badge showing Visitor. The published media card remains visible in the feed.

  • Select Admin in the visible navigation.

You'll see the sign-in form. The protected publishing form stays hidden from the signed-out visitor.

  • Select Home in the visible navigation.
  • Find the public card with the title you entered.

You'll see an image preview for image media, a View flyer action for a PDF flyer, or playback controls for a video. The card remains available without an admin session.

You have proved the full publishing boundary. Visitors can consume chapter media without receiving upload access.

Published Card Missing?

  • Click Refresh feed to request the latest published records.
  • Look for the exact title entered before the upload.
  • Confirm that the earlier upload reached 100% and displayed Published successfully..

Still missing? Help me trace why my published media card is absent from the public feed.

Secret mission

Prove the Admin Boundary with a Second User

A second identity turns your role rules into a live security test. Prove the confirmed account can browse while publishing stays blocked. Adding its UUID to public.admin_users should reveal the uploader with no frontend code change.

Clean Up Your Resources

Clean Up Your Resources

Your local Vite app has no ongoing cost. Choose whether to keep, pause, or delete the hosted Supabase resources while they remain within the $0 per month Free plan quotas.

Cost warning

The Supabase Free plan includes 1 GB of file storage. Its maximum upload size is 50 MB.

The app's 45 MB safety limit stays below that per-file ceiling. Every stored file still counts toward the 1 GB total.

Resources you used:

  • Your local skillsusa-chapter-media-hub folder contains the app files plus installed npm dependencies.
  • Your public chapter-media Storage bucket contains the uploaded media objects.
  • Your public.media_items table contains the published media records.
  • Your public.admin_users table contains membership rows for both confirmed administrators.
  • Your private schema contains the private.is_admin() authorization function.
  • Your applied Row Level Security policies protect metadata plus Storage operations.
  • Your Supabase Auth project contains two confirmed test users.

Keep everything running

No action needed. This option keeps your working media hub ready for more practice.

  • Leave the local Vite development server running only while you are actively using the app.
  • Open the Supabase project periodically so the Free project is not left inactive.
  • Keep both confirmed Auth users available for future authorization demonstrations.
  • Keep only non-sensitive media inside the public chapter-media bucket.

Supabase Free projects can pause after one week of inactivity. Your local project files remain available even if the hosted project pauses.

Pause - I'll come back to this later

Pause the active services while preserving the project for another session. Your code plus hosted data remain available for later use.

  • Return to the Visual Studio Code PowerShell terminal that is running Vite.
  • Press Ctrl+C to stop the local development server.
  • Pause the Supabase project from its Dashboard while you are not demonstrating the app.
  • Leave the skillsusa-chapter-media-hub folder in its current location.

Your local code remains intact. The project can return to the same working state when you resume the hosted resources plus the Vite server.

Delete - I don't want to use this again

Deletion is permanent, which can feel like a big reset. This path targets the resources created for this project.

Clear the hosted media first:

  • Download any uploaded media that you want to keep from chapter-media.
  • Use Storage in your Supabase project to remove every object from chapter-media.
  • Delete the empty chapter-media bucket from Storage.

Remove the database resources next:

  • Use the Supabase SQL Editor to drop the public.media_items table.
  • Use the SQL Editor to remove the Admins can upload chapter media policy from storage.objects.
  • Use the SQL Editor to remove the Admins can inspect chapter media policy from storage.objects.
  • Use the SQL Editor to remove the Admins can remove failed chapter media policy from storage.objects.
  • Use the SQL Editor to drop the private schema containing private.is_admin().
  • Use the SQL Editor to drop the public.admin_users table.

Finish with the test identities plus local files:

  • Use the Supabase authentication user-management page to remove the first confirmed test user.
  • Use the Supabase authentication user-management page to remove the second confirmed test user.
  • Return to the Visual Studio Code PowerShell terminal that is running Vite.
  • Press Ctrl+C to stop the local development server.
  • Use File Explorer search to locate the skillsusa-chapter-media-hub folder.
  • Delete the skillsusa-chapter-media-hub folder from its existing location in File Explorer.

This removes the app files, local environment configuration, SQL files, plus installed npm dependencies. The hosted media resources plus both test identities are also removed.

Nice Work!

Nice Work!

You did it! Your SkillsUSA media hub now gives visitors a responsive public feed. Verified admins have a protected publishing workspace for chapter media.

You've learned how to:

  • Built a responsive application shell that switches from a desktop sidebar to fixed mobile navigation.
  • Connected the public feed to Supabase for visitor access. Protected publishing with explicit admin membership plus Row Level Security.
  • Published chapter media through resumable uploads with visible progress plus retry support.
  • Completed the optional Secret Mission by proving a second account stayed blocked until its user ID was added to public.admin_users.

Ready to quiz yourself?