feat: agentsmd and change source

This commit is contained in:
2026-01-16 19:36:54 +01:00
parent ec1f1614a1
commit 160fb0d36f
4 changed files with 279 additions and 14 deletions

223
AGENTS.md Normal file
View File

@@ -0,0 +1,223 @@
# Agent Guidelines for Helium
## Project Overview
Helium is a Nuxt 3 application for effortless WebRTC screensharing with:
- **Framework**: Nuxt 4 (Vue 3 + TypeScript)
- **Package Manager**: pnpm
- **Database**: PostgreSQL with Drizzle ORM
- **Auth**: Clerk
- **UI**: shadcn-vue + Tailwind CSS v4
- **State**: Pinia stores
- **i18n**: @nuxtjs/i18n (English & Spanish)
## Build/Dev/Test Commands
### Development
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
pnpm generate # Generate static site
```
### Database
```bash
pnpm db:migrate # Generate and run migrations
```
### UI Components
```bash
pnpm ui:add [component] # Add shadcn-vue component
```
### Running Single Tests
Currently no test framework is configured. If adding tests, recommend Vitest for unit tests and Playwright for e2e.
## Project Structure
```
app/
├── components/ # Vue components
│ ├── app/ # Application-specific components
│ └── ui/ # shadcn-vue UI components
├── composables/ # Vue composables (e.g., useWebSocketUrl)
├── layouts/ # Nuxt layouts
├── lib/ # Utilities, DB schema, types
│ ├── db/ # Database schema and utils
│ ├── schema/ # Zod validation schemas
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Helper functions
├── middleware/ # Route middleware (e.g., auth)
├── pages/ # File-based routing
├── plugins/ # Nuxt plugins
└── state/ # Pinia stores
server/
├── api/ # API endpoints
├── cron/ # Scheduled tasks
└── routes/ # Server routes (e.g., WebSocket)
drizzle/ # Database migrations
i18n/ # Translation files
public/ # Static assets
```
## Code Style Guidelines
### TypeScript
- **Always use TypeScript**: No `.js` files. All code must be typed.
- **Type imports**: Use `import type` for type-only imports
```typescript
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
```
- **Explicit return types**: Prefer explicit return types for functions
- **No `any`**: Avoid `any` type. Use `unknown` or proper typing
- **Interface vs Type**: Use `interface` for object shapes, `type` for unions/intersections
### Imports
- **Path aliases**: Use `@/` for app directory imports and `~/` for root imports
```typescript
import { cn } from "@/lib/utils";
import { schema } from "~/lib/schema/new-preset";
```
- **Import order**:
1. External packages
2. Vue/Nuxt imports
3. Type imports
4. Internal utilities
5. Components
6. Relative imports
### Vue Components
- **Script Setup**: Always use `<script setup lang="ts">`
- **Props**: Define with TypeScript interfaces
```typescript
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
});
```
- **Template**: Keep templates clean, extract complex logic to composables
- **Component naming**: PascalCase for components, kebab-case for files in multi-word components
### Naming Conventions
- **Files**:
- Components: PascalCase (e.g., `Button.vue`, `EditPresetDialog.vue`)
- Composables: camelCase starting with `use` (e.g., `useWebSocketUrl.ts`)
- API routes: kebab-case with HTTP method (e.g., `[id].get.ts`, `[id].delete.ts`)
- Database: camelCase for tables (e.g., `schema.ts`, `migrate.ts`)
- **Variables**: camelCase
- **Constants**: UPPER_SNAKE_CASE for true constants, camelCase for config objects
- **Functions**: camelCase, descriptive verbs (e.g., `createPreset`, `getPresetById`)
- **Components**: PascalCase
### Database (Drizzle)
- **Schema location**: `app/lib/db/schema.ts`
- **Relations**: Define relations separately after table definitions
- **Column naming**: camelCase in TypeScript, snake_case in SQL
```typescript
export const presets = pgTable("presets", {
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
```
- **Migrations**: Always generate migrations with `pnpm db:migrate`
### API Routes (Server)
- **Event handlers**: Use `defineEventHandler`
- **Auth**: Check `event.context.auth()` for authentication
```typescript
const { isAuthenticated, userId } = event.context.auth();
if (!isAuthenticated || !userId) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
```
- **Error handling**: Use `createError` for HTTP errors
- **Validation**: Use Zod schemas from `app/lib/schema/`
- **Response format**: Consistent structure with `success` and `data`/`message` fields
```typescript
return {
success: true,
data: preset,
};
```
### Error Handling
- **Server**: Use `createError()` with proper status codes
- **Client**: Use `toast` from `vue-sonner` for user feedback
```typescript
try {
await $fetch("/api/presets", { method: "POST" });
toast.success(t("presetCreatedSuccessfully"));
} catch (error) {
toast.error(t("failedToCreatePreset"));
}
```
- **Validation**: Use Zod with `.safeParse()` and handle errors
- **Always catch promises**: Never leave promises unhandled
### Formatting
- **Indentation**: 2 spaces
- **Quotes**: Double quotes for strings
- **Semicolons**: Optional but consistent within files
- **Line length**: Aim for 80-100 characters, break at 120
- **Trailing commas**: Use in multiline objects/arrays
- **Comments**: Prevent comments where possible, if the code is not understandable by itself.
### State Management
- **Pinia stores**: Located in `app/state/`
- **Store naming**: Descriptive (e.g., `streamer.ts`, `viewer.ts`)
- **Composables**: Prefer composables over stores for simple state
### i18n
- **Always use**: Use `useI18n()` composable for all user-facing strings. That is, you must localize any user-facing text.
```typescript
const { t } = useI18n();
<h1>{{ t('presets') }}</h1>
```
- **Keys**: camelCase (e.g., `createNewPreset`, `deletePresetConfirm`)
- **Files**: `i18n/locales/en.json` and `i18n/locales/es.json`
## Important Notes
- **Authentication**: All protected routes must use auth middleware
- **WebSocket**: Available at `/ws/signaling` for real-time communication
- **Environment**: Use `.env` file (not committed) for DATABASE_URL and Clerk keys
- **Clerk**: Auth provider - use `useAuth()` and `useUser()` composables
- **Styling**: Use Tailwind utility classes, `cn()` helper for conditional classes

View File

@@ -1,7 +1,16 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<div class="flex space-x-4 items-center">
<Button @click="startScreenShare"> {{ $t('screenshare') }} </Button>
<Button v-if="!localStream" @click="startScreenShare">
{{ $t("screenshare") }}
</Button>
<Button
v-if="localStream"
@click="changeScreenShareSource"
variant="outline"
>
{{ $t("changeSource") }}
</Button>
<PresetSelect />
</div>
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
@@ -122,7 +131,6 @@ async function startScreenShare() {
videofeedRef.value.srcObject = stream;
}
// Detect when user stops sharing via browser UI
stream.getTracks().forEach((track) => {
track.onended = () => {
console.log("Screen sharing stopped by user");
@@ -137,13 +145,51 @@ async function startScreenShare() {
);
} catch (error) {
console.error("Failed to start screen share:", error);
// User cancelled or permission denied
cleanupStreaming();
}
}
async function changeScreenShareSource() {
try {
const newStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
if (!localStream.value) return;
const newVideoTrack = newStream.getVideoTracks()[0];
newVideoTrack.onended = () => {
console.log("Screen sharing stopped by user");
cleanupStreaming();
};
Object.values(streamerStore.peerConnections).forEach((pc) => {
const senders = pc.getSenders();
const videoSender = senders.find(
(sender) => sender.track?.kind === "video",
);
if (videoSender) {
videoSender.replaceTrack(newVideoTrack);
}
});
localStream.value.getTracks().forEach((track) => {
track.stop();
});
localStream.value = newStream;
if (videofeedRef.value) {
videofeedRef.value.srcObject = newStream;
}
} catch (error) {
console.error("Failed to change screen share source:", error);
}
}
function cleanupStreaming() {
// Stop all media tracks
if (localStream.value) {
localStream.value.getTracks().forEach((track) => {
track.stop();
@@ -151,30 +197,24 @@ function cleanupStreaming() {
localStream.value = null;
}
// Close all peer connections
Object.values(streamerStore.peerConnections).forEach((pc) => {
pc.close();
});
// Clear peer connections from store
streamerStore.clearPeerConnections();
// Clear video element
if (videofeedRef.value) {
videofeedRef.value.srcObject = null;
}
// Clear room code
streamerStore.setCode("");
}
// Cleanup on component unmount
onBeforeUnmount(() => {
cleanupStreaming();
closeWebSocket();
});
// Cleanup on window/tab close
onMounted(() => {
const handleBeforeUnload = () => {
cleanupStreaming();

View File

@@ -14,7 +14,7 @@
"enterAnotherCode": "Enter another code",
"disconnect": "Disconnect",
"fullscreen": "Fullscreen",
"screenshare": "screenshare",
"screenshare": "Screenshare",
"loadingPresets": "Loading presets...",
"selectAPreset": "Select a preset",
"noPresetsAvailable": "No presets available",
@@ -53,5 +53,6 @@
"presetNotFound": "Preset not found.",
"presetCannotBeShared": "This preset cannot be shared",
"presetSharingDisabled": "The preset owner has not enabled sharing for this preset.",
"goBack": "Go Back"
"goBack": "Go Back",
"changeSource": "Change Source"
}

View File

@@ -14,7 +14,7 @@
"enterAnotherCode": "introduce otro código",
"disconnect": "Desconectar",
"fullscreen": "Pantalla completa",
"screenshare": "compartir pantalla",
"screenshare": "Compartir pantalla",
"loadingPresets": "Cargando ajustes...",
"selectAPreset": "Seleccionar un ajuste",
"noPresetsAvailable": "No hay ajustes disponibles",
@@ -53,5 +53,6 @@
"presetNotFound": "Ajuste no encontrado.",
"presetCannotBeShared": "Este ajuste no se puede compartir",
"presetSharingDisabled": "El propietario del ajuste no ha habilitado el uso compartido para este ajuste.",
"goBack": "Volver"
"goBack": "Volver",
"changeSource": "Cambiar fuente"
}