Recreate the Tube Drop Val Town application with these new features:
- Google Login via LastLogin to support multiple users (each user has their own saved videos)
- YouTube Data API v3 integration to fetch richer video metadata
- Export Functionality to download user data (JSON/CSV)
Use these Val Town conventions:
// React (pin to 18.2.0)
/** @jsxImportSource https://esm.sh/react@18.2.0 */
import React from "https://esm.sh/react@18.2.0";
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
// Backend framework
import { Hono } from "https://esm.sh/hono@4";
// Val Town stdlib
import { sqlite } from "https://esm.town/v/std/sqlite";
import { OpenAI } from "https://esm.town/v/std/openai";
import {
listFiles,
parseProject,
readFile,
serveFile,
} from "https://esm.town/v/std/utils@85-main/index.ts";
// Google Login
import { lastlogin } from "https://esm.town/v/stevekrouse/lastlogin_safe";
import { LoginWithGoogleButton } from "https://esm.town/v/stevekrouse/LoginWithGoogleButton";
<!-- daisyui + tailwind --> <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" /> <!-- favicon --> <link rel="icon" type="image/png" href="https://cdn.midjourney.com/d28cf228-fd1e-4d7b-9999-714316cf0d4d/0_3.png" > <style> body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } /* Glassmorphism utility */ .glass { backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); } /* Sticky group header with theme-aware background */ .group-header { position: sticky; top: 0; z-index: 10; backdrop-filter: blur(10px); background: var(--color-base-100 / 0.9); } </style>
├── app.http.ts # Main Hono app entry (wrapped with lastlogin)
├── backend/
│ ├── database.ts # SQLite schema & queries (user-scoped)
│ ├── ai.ts # OpenAI tag generation
│ └── youtube.ts # YouTube Data API v3 calls
├── frontend/
│ ├── index.html # HTML template
│ ├── index.tsx # React entry point
│ ├── App.tsx # Main React app
│ ├── api.ts # Frontend API client
│ └── components/
│ ├── Controls.tsx # [NEW] Top-mounted toolbar for search, add, filter & sort
│ ├── Icon.tsx
│ ├── Modal.tsx
│ ├── Stars.tsx
│ ├── StatusBanner.tsx
│ ├── VideoCard.tsx
│ └── UserMenu.tsx # [UPDATED] Premium DaisyUI dropdown with theme switcher
└── shared/
├── types.ts # Shared TypeScript interfaces
├── constants.ts
└── safe.ts
Add user_email column to scope videos per user. Use a constant for table name:
CREATE TABLE IF NOT EXISTS ${SQLITE_TABLE_NAME} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_email TEXT NOT NULL, -- NEW: Owner of this video
url TEXT NOT NULL,
title TEXT,
channel TEXT,
channelUrl TEXT,
thumbnail TEXT,
category TEXT,
subcategory TEXT,
tags TEXT, -- JSON array
rating INTEGER DEFAULT 0,
watched BOOLEAN DEFAULT 0,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
publishedAt DATETIME,
videoId TEXT,
-- YouTube API enriched fields:
description TEXT,
duration TEXT, -- e.g., "PT5M42S" or human-readable
viewCount INTEGER,
likeCount INTEGER,
channelAvatar TEXT, -- NEW
channelHeader TEXT -- NEW
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tube_drop_v3_user_url
ON tube_drop_videos_v3(user_email, url);
In Val Town, sqlite.execute returns rows as an array of arrays (values
only). You MUST manually map these to objects using the columns property
to avoid "undefined" values on the frontend.
// backend/database.ts
function mapRowToObject(row: any[], columns: string[]): any {
return columns.reduce((obj, col, i) => ({ ...obj, [col]: row[i] }), {});
}
export async function listVideos(userEmail: string) {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${SQLITE_TABLE_NAME} WHERE user_email = ?`,
args: [userEmail],
});
return rows.map((r) => mapRowToObject(r, columns));
}
// CRITICAL: Use result.lastInsertRowid (note the lowercase 'd') to get the new ID.
const result = await sqlite.execute("INSERT...");
const id = Number(result.lastInsertRowid);
Wrap the Hono app with lastlogin:
// backend/index.http.ts
import { lastlogin } from "https://esm.town/v/stevekrouse/lastlogin_safe";
type Variables = {
userEmail: string | null;
};
const app = new Hono<{ Variables: Variables }>();
// Re-throw errors to see full stack traces in logs
app.onError((err, c) => {
throw err;
});
// Get email from request headers
app.use("*", async (c, next) => {
const email = c.req.header("X-LastLogin-Email");
if (!email && c.req.path.startsWith("/api/")) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("userEmail", email || null);
await next();
});
// ... routes ...
// Export wrapped with lastlogin
export default lastlogin(app.fetch);
Instead of server-side injection, the frontend should fetch user info from a dedicated endpoint. This allows for cleaner static file serving.
// frontend/App.tsx
export default function App() {
const [user, setUser] = useState<{ email: string | null } | null>(null);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then(setUser);
}, []);
if (!user) return <LoadingSpinner />;
if (!user.email) return <LoginScreen />;
return <MainApp email={user.email} />;
}
// backend route
app.get("/api/user", (c) => {
return c.json({ email: c.get("userEmail") });
});
// Serve index.html
app.get("/", async (c) => {
const html = await readFile("/frontend/index.html", import.meta.url);
return c.html(html);
});
// Serve static assets correctly using import.meta.url
app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url));
app.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url));
The app uses DaisyUI's semantic classes to support automatic theming.
// frontend/App.tsx
export default function App() {
const [user, setUser] = useState<{ email: string | null } | null>(null);
useEffect(() => {
fetch("/api/user").then((res) => res.json()).then(setUser);
}, []);
if (!user) return <LoadingSpinner />;
return (
<div className="bg-base-100 min-h-screen text-base-content">
{!user.email ? <LoginScreen /> : <MainApp email={user.email} />}
</div>
);
}
Use DaisyUI's dropdown and dropdown-hover classes. Ensure no "mouse gap" by using padding on the content container.
// frontend/components/UserMenu.tsx
export default function UserMenu({ email }: { email: string }) {
const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute("data-theme") || "nord"
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
return (
<div className="dropdown dropdown-end dropdown-hover">
<button type="button" tabIndex={0} className="btn btn-ghost btn-sm flex items-center gap-2">
<img src={avatarUrl} className="rounded-full w-7 h-7" />
<span className="hidden sm:inline truncate max-w-[120px]">{email}</span>
<Icon icon="mdi:chevron-down" className="opacity-50 text-xs" />
</button>
<div className="dropdown-content z-[50] w-64 pt-2">
<div className="bg-base-200 text-base-content shadow-2xl border border-base-300 rounded-xl overflow-hidden glass p-4">
{/* Theme selector and sign out links */}
</div>
</div>
</div>
);
}
Store YOUTUBE_API_KEY in Val Town environment variables.
// backend/youtube.ts
const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
interface YouTubeVideoDetails {
title: string;
channel: string;
channelId: string;
channelUrl: string;
thumbnail: string;
description: string;
publishedAt: string;
duration: string;
viewCount: number;
likeCount: number;
channelAvatar?: string | null; -- NEW
channelHeader?: string | null; -- NEW
}
export async function fetchVideoDetails(
videoId: string,
): Promise<YouTubeVideoDetails | null> {
if (!YOUTUBE_API_KEY) return null;
const url = new URL("https://www.googleapis.com/youtube/v3/videos");
url.searchParams.set("part", "snippet,contentDetails,statistics");
url.searchParams.set("id", videoId);
url.searchParams.set("key", YOUTUBE_API_KEY);
const res = await fetch(url.toString());
if (!res.ok) return null;
const data = await res.json();
const item = data.items?.[0];
if (!item) return null;
return {
title: item.snippet.title,
channel: item.snippet.channelTitle,
channelId: item.snippet.channelId,
channelUrl:
`https://www.youtube.com/channel/${item.snippet.channelId}/videos`,
thumbnail: item.snippet.thumbnails?.maxres?.url ??
item.snippet.thumbnails?.high?.url ??
item.snippet.thumbnails?.default?.url,
description: item.snippet.description,
publishedAt: item.snippet.publishedAt,
duration: item.contentDetails.duration, // ISO 8601 format
viewCount: parseInt(item.statistics.viewCount ?? "0"),
likeCount: parseInt(item.statistics.likeCount ?? "0"),
};
}
- Extract
videoIdfrom URL - Call
fetchVideoDetails(videoId) - Store enriched metadata in database
- Fall back to oEmbed if no API key or API fails
// Export as JSON
app.get("/api/export/json", async (c) => {
const email = c.get("userEmail");
const videos = await listVideos(email);
return c.json(videos);
});
// Export as CSV
app.get("/api/export/csv", async (c) => {
const email = c.get("userEmail");
const videos = await listVideos(email);
const headers = [
"id",
"url",
"title",
"channel",
"category",
"subcategory",
"tags",
"rating",
"watched",
"publishedAt",
"timestamp",
];
const csvRows = [
headers.join(","),
...videos.map((v) =>
headers.map((h) => {
const val = v[h];
if (Array.isArray(val)) return `"${val.join("; ")}"`;
if (
typeof val === "string" && (val.includes(",") || val.includes('"'))
) {
return `"${val.replace(/"/g, '""')}"`;
}
return val ?? "";
}).join(",")
),
];
return new Response(csvRows.join("\n"), {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="tube-drop-export-${
new Date().toISOString().slice(0, 10)
}.csv"`,
},
});
});
Add export buttons to UserMenu:
function UserMenu({ email }: { email: string }) {
return (
<div className="relative group">
<button className="flex items-center gap-2 text-gray-300 hover:text-white">
<img
src={`https://ui-avatars.com/api/?name=${
encodeURIComponent(email)
}&background=ef4444&color=fff`}
alt="avatar"
className="w-8 h-8 rounded-full"
/>
<span className="text-sm truncate max-w-[150px]">{email}</span>
</button>
<div className="hidden group-hover:block absolute right-0 mt-2 w-48 bg-neutral-800 rounded-lg shadow-xl py-2">
<a
href="/api/export/json"
className="block px-4 py-2 text-sm text-gray-300 hover:bg-neutral-700"
>
Export as JSON
</a>
<a
href="/api/export/csv"
className="block px-4 py-2 text-sm text-gray-300 hover:bg-neutral-700"
>
Export as CSV
</a>
<hr className="my-2 border-neutral-700" />
<a
href="/auth/logout"
className="block px-4 py-2 text-sm text-red-400 hover:bg-neutral-700"
>
Sign Out
</a>
</div>
</div>
);
}
- Save YouTube URLs (paste, drag-drop, or input)
- Automatic video metadata fetching (title, channel, thumbnail)
- AI-powered auto-tagging (category, subcategory, tags) using OpenAI
- Edit video details (title, category, subcategory, tags, rating, watched status)
- Delete videos (with double-click confirmation)
- Copy URL to clipboard
- Top Bar: All controls reside in a sticky
#app-controlstoolbar. - Search: Real-time search by title, channel, URL, or tags.
- Improved Filter Inputs: Use
inputwithdatalistfor Category, Subcategory, and Tag to allow custom text entry and suggestions. - Watched Toggle: Use a DaisyUI
joingroup for a segmented toggle (All, Watched, Unwatched) instead of a select dropdown. - Sort By: Saved date, published date, title, rating.
- Clear Filters: A quick-action button to reset all filter state.
- Dynamic Grouping: Group by none, date, category, or published year.
- Group Sorting: Sort groups themselves in Alphabetical Ascending or Descending order.
- Config Presets: One-click configuration for common filters (e.g., "2018" history view, "AI Coding" study view).
- Sticky Headers: Sticky group headers with calculated video counts.
- Toggle Elements: Global settings to show/hide specific card components (Thumbnail, Title, Channel, Description, Rating, Tags, Category, Metadata).
- Theme Consistency: All modals use
bg-base-100to ensure readability across 30+ DaisyUI themes. - Repositioned Feedback: Success/Error toasts are centered at the bottom for better visibility.
- DaisyUI Support: The app MUST use semantic classes (
bg-base-100,bg-base-200,text-base-content,border-base-300) to support automatic 30+ DaisyUI themes. - NEVER use hardcoded backgrounds like
bg-neutral-900ortext-whiteon the main container. - Glassmorphism: Use the
.glassutility for sidebars and modals. - Status Indicators: Use DaisyUI
alert alert-info/success/errorclasses for notifications. - Buttons/Inputs: Use
btn btn-primary btn-smandinput input-bordered. - Icons: Use
@iconify/reactto prevent React hydration crashes.
- Sticky Controls Header: Use a sticky top bar for all actions:
<div id="app-controls" className="sticky top-0 z-10 border-base-300 bg-base-100/80 backdrop-blur-md border-b p-4">
- No Sidebar: The layout is a single vertical column, centered with
mx-auto max-w-7xl. - Top Actions: Combine the search bar and the "Add Video" input in the top row.
- Grid Layout: Videos display in a dense vertical list or grid below the controls.
- Glassmorphism: Use the
.glassutility for the sticky header and modals. - Local Storage: Persist filters, settings, and theme choice.
interface VideoCardElements {
thumbnail: boolean;
title: boolean;
channel: boolean;
rating: boolean;
tags: boolean;
category: boolean;
metadata: boolean;
description: boolean;
}
interface SettingsState {
sortBy: "savedDate" | "publishedDate" | "title" | "rating";
sortOrder: "asc" | "desc";
groupBy: "none" | "date" | "category" | "year";
groupOrder: "asc" | "desc";
visibleElements: VideoCardElements;
}
interface VideoRow {
id: number;
url: string;
title: string | null;
channel: string | null;
channelUrl: string | null;
thumbnail: string | null;
category: string | null;
subcategory: string | null;
tags: string[];
rating: number;
watched: boolean;
timestamp: string;
publishedAt: string | null;
videoId: string | null;
// YouTube API enriched fields:
description?: string | null;
duration?: string | null;
viewCount?: number | null;
likeCount?: number | null;
channelAvatar?: string | null;
channelHeader?: string | null;
}
| Method | Path | Description |
|---|---|---|
| GET | / | Serve index.html |
| GET | /api/user | Get current user email |
| GET | /frontend/* | Serve static files (w/ import.meta.url) |
| GET | /shared/* | Serve shared files (w/ import.meta.url) |
| GET | /api/videos | List user's videos |
| POST | /api/videos | Create video (with YouTube API enrichment) |
| PATCH | /api/videos/:id | Update video |
| DELETE | /api/videos/:id | Delete video |
| GET | /api/categories | Get user's unique categories/subcategories |
| POST | /api/ai/generate-tags | Generate AI tags |
| GET | /api/export/json | Export user data as JSON |
| GET | /api/export/csv | Export user data as CSV |
- Add
lastloginwrapper tobackend/index.http.ts - Add
user_emailcolumn to database schema - Scope all database queries by
user_email - Create
backend/youtube.tswith YouTube API v3 calls - Integrate YouTube API in video creation flow
- Add
/api/export/jsonand/api/export/csvendpoints - Update frontend to show login screen when unauthenticated
- Add
UserMenucomponent with export & logout - Implement
/api/userendpoint and frontend auth fetch - Update all TypeScript types with new fields
- Test multi-user isolation
- Test export functionality
| Variable | Description |
|---|---|
YOUTUBE_API_KEY | YouTube Data API v3 key (get one here) |
LastLogin handles Google OAuth automatically - no additional env vars needed.