Update README.md with setup instructions, project stack details, chat functionality, and PocketBase integration information.
This commit is contained in:
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user