mirror of
https://github.com/SrIzan10/helium.git
synced 2026-06-06 00:56:58 +00:00
feat: agentsmd and change source
This commit is contained in:
223
AGENTS.md
Normal file
223
AGENTS.md
Normal 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
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
|
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
|
||||||
<div class="flex space-x-4 items-center">
|
<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 />
|
<PresetSelect />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
|
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>
|
||||||
@@ -122,7 +131,6 @@ async function startScreenShare() {
|
|||||||
videofeedRef.value.srcObject = stream;
|
videofeedRef.value.srcObject = stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect when user stops sharing via browser UI
|
|
||||||
stream.getTracks().forEach((track) => {
|
stream.getTracks().forEach((track) => {
|
||||||
track.onended = () => {
|
track.onended = () => {
|
||||||
console.log("Screen sharing stopped by user");
|
console.log("Screen sharing stopped by user");
|
||||||
@@ -137,13 +145,51 @@ async function startScreenShare() {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start screen share:", error);
|
console.error("Failed to start screen share:", error);
|
||||||
// User cancelled or permission denied
|
|
||||||
cleanupStreaming();
|
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() {
|
function cleanupStreaming() {
|
||||||
// Stop all media tracks
|
|
||||||
if (localStream.value) {
|
if (localStream.value) {
|
||||||
localStream.value.getTracks().forEach((track) => {
|
localStream.value.getTracks().forEach((track) => {
|
||||||
track.stop();
|
track.stop();
|
||||||
@@ -151,30 +197,24 @@ function cleanupStreaming() {
|
|||||||
localStream.value = null;
|
localStream.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all peer connections
|
|
||||||
Object.values(streamerStore.peerConnections).forEach((pc) => {
|
Object.values(streamerStore.peerConnections).forEach((pc) => {
|
||||||
pc.close();
|
pc.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear peer connections from store
|
|
||||||
streamerStore.clearPeerConnections();
|
streamerStore.clearPeerConnections();
|
||||||
|
|
||||||
// Clear video element
|
|
||||||
if (videofeedRef.value) {
|
if (videofeedRef.value) {
|
||||||
videofeedRef.value.srcObject = null;
|
videofeedRef.value.srcObject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear room code
|
|
||||||
streamerStore.setCode("");
|
streamerStore.setCode("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on component unmount
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
cleanupStreaming();
|
cleanupStreaming();
|
||||||
closeWebSocket();
|
closeWebSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on window/tab close
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
cleanupStreaming();
|
cleanupStreaming();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"enterAnotherCode": "Enter another code",
|
"enterAnotherCode": "Enter another code",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
"screenshare": "screenshare",
|
"screenshare": "Screenshare",
|
||||||
"loadingPresets": "Loading presets...",
|
"loadingPresets": "Loading presets...",
|
||||||
"selectAPreset": "Select a preset",
|
"selectAPreset": "Select a preset",
|
||||||
"noPresetsAvailable": "No presets available",
|
"noPresetsAvailable": "No presets available",
|
||||||
@@ -53,5 +53,6 @@
|
|||||||
"presetNotFound": "Preset not found.",
|
"presetNotFound": "Preset not found.",
|
||||||
"presetCannotBeShared": "This preset cannot be shared",
|
"presetCannotBeShared": "This preset cannot be shared",
|
||||||
"presetSharingDisabled": "The preset owner has not enabled sharing for this preset.",
|
"presetSharingDisabled": "The preset owner has not enabled sharing for this preset.",
|
||||||
"goBack": "Go Back"
|
"goBack": "Go Back",
|
||||||
|
"changeSource": "Change Source"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"enterAnotherCode": "introduce otro código",
|
"enterAnotherCode": "introduce otro código",
|
||||||
"disconnect": "Desconectar",
|
"disconnect": "Desconectar",
|
||||||
"fullscreen": "Pantalla completa",
|
"fullscreen": "Pantalla completa",
|
||||||
"screenshare": "compartir pantalla",
|
"screenshare": "Compartir pantalla",
|
||||||
"loadingPresets": "Cargando ajustes...",
|
"loadingPresets": "Cargando ajustes...",
|
||||||
"selectAPreset": "Seleccionar un ajuste",
|
"selectAPreset": "Seleccionar un ajuste",
|
||||||
"noPresetsAvailable": "No hay ajustes disponibles",
|
"noPresetsAvailable": "No hay ajustes disponibles",
|
||||||
@@ -53,5 +53,6 @@
|
|||||||
"presetNotFound": "Ajuste no encontrado.",
|
"presetNotFound": "Ajuste no encontrado.",
|
||||||
"presetCannotBeShared": "Este ajuste no se puede compartir",
|
"presetCannotBeShared": "Este ajuste no se puede compartir",
|
||||||
"presetSharingDisabled": "El propietario del ajuste no ha habilitado el uso compartido para este ajuste.",
|
"presetSharingDisabled": "El propietario del ajuste no ha habilitado el uso compartido para este ajuste.",
|
||||||
"goBack": "Volver"
|
"goBack": "Volver",
|
||||||
|
"changeSource": "Cambiar fuente"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user