Update README.md with setup instructions, project stack details, chat functionality, and PocketBase integration information.

This commit is contained in:
Ezekiel Ewing
2026-02-07 12:02:57 -06:00
parent 894d10eed6
commit ffef3b14a1
43 changed files with 2059 additions and 0 deletions
@@ -0,0 +1,71 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
interface Props {
channelId: string;
onSent?: () => void;
}
let { channelId, onSent }: Props = $props();
let uploading = $state(false);
let caption = $state('');
let fileInput: HTMLInputElement;
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || uploading) return;
const isVideo = file.type.startsWith('video/');
const type = file.type.startsWith('image/') ? 'image' : isVideo ? 'video' : null;
if (!type) return;
uploading = true;
try {
const formData = new FormData();
formData.append('type', type);
formData.append('file', file);
if (caption.trim()) formData.append('body', caption.trim());
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? 'Upload failed');
}
caption = '';
input.value = '';
onSent?.();
} catch (err) {
console.error('Upload failed', err);
} finally {
uploading = false;
}
}
</script>
<div class="flex flex-wrap items-center gap-2">
<input
bind:this={fileInput}
type="file"
accept="image/*,video/*"
class="hidden"
onchange={onFileChange}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onclick={() => fileInput?.click()}
>
{uploading ? 'Uploading…' : 'Photo / Video'}
</Button>
<input
type="text"
bind:value={caption}
placeholder="Caption (optional)"
class="max-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-sm"
disabled={uploading}
/>
</div>
@@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { createPocketBase } from '$lib/pocketbase';
import { getMessageFileUrl } from '$lib/pocketbase.types';
import type { MessageRecord } from '$lib/pocketbase.types';
interface Props {
channelId: string;
initialMessages: MessageRecord[];
currentUserId: string;
}
let { channelId, initialMessages = [], currentUserId }: Props = $props();
const messagesStore = writable<MessageRecord[]>([]);
let pb: ReturnType<typeof createPocketBase> | null = null;
onMount(() => {
messagesStore.set([...initialMessages]);
pb = createPocketBase();
pb.collection('messages').subscribe('*', (e) => {
if (e.action === 'create' && e.record && (e.record as MessageRecord).channel === channelId) {
messagesStore.update((m) => [...m, e.record as MessageRecord]);
}
});
return () => {
try {
pb?.collection('messages').unsubscribe('*');
} catch {}
};
});
function getFileUrl(msg: MessageRecord, field: 'audio' | 'file') {
if (!pb) return '';
return getMessageFileUrl(pb, msg, field);
}
</script>
<div class="flex flex-col gap-3 py-4 min-h-[200px]">
{#each $messagesStore as msg (msg.id)}
{@const isSent = msg.user === currentUserId}
<div
class="flex {isSent ? 'justify-end' : 'justify-start'}"
class:sent={isSent}
>
<div
class="max-w-[85%] rounded-2xl px-4 py-2 {isSent
? 'bg-primary text-primary-foreground'
: 'bg-muted'}"
>
{#if msg.type === 'voice'}
<div class="flex flex-col gap-1">
<audio
controls
class="h-9 min-w-[200px]"
src={getFileUrl(msg, 'audio')}
></audio>
{#if msg.duration != null}
<span class="text-xs opacity-80">{msg.duration.toFixed(1)}s</span>
{/if}
</div>
{:else if msg.type === 'text'}
<p class="whitespace-pre-wrap break-words">{msg.body ?? ''}</p>
{:else if msg.type === 'image'}
<div class="flex flex-col gap-1">
<img
src={getFileUrl(msg, 'file')}
alt={msg.body ?? 'Image'}
class="max-w-full max-h-64 rounded-lg object-contain"
/>
{#if msg.body}
<p class="text-sm whitespace-pre-wrap">{msg.body}</p>
{/if}
</div>
{:else if msg.type === 'video'}
<div class="flex flex-col gap-1">
<video
controls
class="max-w-full max-h-64 rounded-lg"
src={getFileUrl(msg, 'file')}
>
<track kind="captions" />
</video>
{#if msg.body}
<p class="text-sm whitespace-pre-wrap">{msg.body}</p>
{/if}
</div>
{:else}
<p class="text-sm opacity-80">Unknown message type</p>
{/if}
</div>
</div>
{/each}
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
interface Props {
channelId: string;
placeholder?: string;
onSent?: () => void;
}
let { channelId, placeholder = 'Message...', onSent }: Props = $props();
let text = $state('');
let sending = $state(false);
async function send() {
const trimmed = text.trim();
if (!trimmed || sending) return;
sending = true;
text = '';
try {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text', body: trimmed }),
credentials: 'include'
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? 'Send failed');
}
onSent?.();
} catch (err) {
console.error('Send failed', err);
text = trimmed;
} finally {
sending = false;
}
}
function handleSubmit(e: Event) {
e.preventDefault();
send();
}
</script>
<form class="flex gap-2 items-end" onsubmit={handleSubmit}>
<input
type="text"
bind:value={text}
placeholder={placeholder}
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
disabled={sending}
/>
<Button type="submit" disabled={sending || !text.trim()}>Send</Button>
</form>
@@ -0,0 +1,74 @@
<script lang="ts" module>
import type { WithElementRef } from "bits-ui";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{href}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
channelId: string;
}
let { channelId }: Props = $props();
let isRecording = $state(false);
let duration = $state(0);
let micError = $state<string | null>(null);
let mediaRecorder: MediaRecorder | null = $state(null);
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
let startTime = 0;
let timerId: ReturnType<typeof setInterval> | null = null;
const MIN_DURATION = 0.3;
async function startRecording() {
micError = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunks.push(e.data);
};
mediaRecorder.onstop = async () => {
const d = (Date.now() - startTime) / 1000;
if (d < MIN_DURATION) {
stream?.getTracks().forEach((t) => t.stop());
return;
}
const blob = new Blob(audioChunks, { type: mimeType });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: mimeType });
const formData = new FormData();
formData.append('type', 'voice');
formData.append('duration', String(Math.round(d)));
formData.append('audio', file);
try {
const res = await fetch(`/api/channels/${channelId}/messages`, {
method: 'POST',
body: formData,
credentials: 'include'
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? 'Upload failed');
}
} catch (err) {
console.error('Upload failed', err);
}
audioChunks = [];
duration = 0;
};
mediaRecorder.start(250);
startTime = Date.now();
timerId = setInterval(() => {
duration = (Date.now() - startTime) / 1000;
}, 100);
isRecording = true;
} catch (err) {
micError = err instanceof Error ? err.message : 'Microphone access denied';
}
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') {
mediaRecorder.stop();
}
if (timerId) {
clearInterval(timerId);
timerId = null;
}
stream?.getTracks().forEach((t) => t.stop());
stream = null;
mediaRecorder = null;
isRecording = false;
}
function handlePointerDown(e: PointerEvent) {
e.preventDefault();
startRecording();
}
function handlePointerUp(e: PointerEvent) {
e.preventDefault();
if (isRecording) stopRecording();
}
onMount(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
startRecording();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
if (isRecording) stopRecording();
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (isRecording) stopRecording();
};
});
</script>
<div class="voice-ptt-container flex flex-col items-center gap-2">
{#if micError}
<p class="text-sm text-destructive">{micError}</p>
{/if}
<button
type="button"
class="ptt-button h-20 w-20 rounded-full border-none font-bold text-white cursor-pointer select-none touch-manipulation transition-transform
{isRecording ? 'bg-destructive animate-pulse' : 'bg-muted-foreground/80 hover:bg-muted-foreground'}"
onpointerdown={handlePointerDown}
onpointerup={handlePointerUp}
ontouchstart={handlePointerDown}
ontouchend={handlePointerUp}
>
{#if isRecording}
<span class="text-sm">Recording… {duration.toFixed(1)}s</span>
{:else}
<span class="text-sm">Hold to talk</span>
{/if}
</button>
</div>
+12
View File
@@ -0,0 +1,12 @@
import PocketBase from 'pocketbase';
import { env } from '$env/dynamic/public';
const PB_URL = env.PUBLIC_POCKETBASE_URL ?? 'https://pocketbase.ccllc.pro';
/** Create a PocketBase client. Use one instance per request on the server; safe to use a single instance on the client. */
export function createPocketBase() {
return new PocketBase(PB_URL);
}
/** Base URL for API (e.g. for fetch or env reference). */
export const POCKETBASE_URL = PB_URL;
+43
View File
@@ -0,0 +1,43 @@
import type PocketBase from 'pocketbase';
export type MessageType = 'voice' | 'text' | 'image' | 'video';
export interface ChannelRecord {
id: string;
name: string;
members: string[];
created: string;
createdBy?: string;
expand?: {
members?: { id: string; [key: string]: unknown }[];
createdBy?: { id: string; [key: string]: unknown };
};
}
export interface MessageRecord {
id: string;
type: MessageType;
channel: string;
user: string;
body?: string;
audio?: string;
file?: string;
duration?: number;
waveform?: { peaks?: number[] };
created: string;
expand?: {
user?: { id: string; [key: string]: unknown };
channel?: ChannelRecord;
};
}
/** Returns the file URL for a message's audio or file field. Use for playback and media display. */
export function getMessageFileUrl(
pb: PocketBase,
msg: MessageRecord,
field: 'audio' | 'file'
): string {
const filename = msg[field];
if (!filename) return '';
return pb.files.getUrl(msg, filename);
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}