212 lines
5.1 KiB
Svelte
212 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import { Button, Input, Avatar } from "$lib/components/ui";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { Database } from "$lib/supabase/types";
|
|
import { toasts } from "$lib/stores/toast.svelte";
|
|
import { invalidateAll } from "$app/navigation";
|
|
|
|
interface Props {
|
|
supabase: SupabaseClient<Database>;
|
|
org: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
avatar_url?: string | null;
|
|
};
|
|
isOwner: boolean;
|
|
onLeave: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
let { supabase, org, isOwner, onLeave, onDelete }: Props = $props();
|
|
|
|
let orgName = $state(org.name);
|
|
let orgSlug = $state(org.slug);
|
|
let avatarUrl = $state(org.avatar_url ?? null);
|
|
let isSaving = $state(false);
|
|
let isUploading = $state(false);
|
|
let avatarInput = $state<HTMLInputElement | null>(null);
|
|
|
|
$effect(() => {
|
|
orgName = org.name;
|
|
orgSlug = org.slug;
|
|
avatarUrl = org.avatar_url ?? null;
|
|
});
|
|
|
|
async function handleAvatarUpload(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Validate file
|
|
if (!file.type.startsWith("image/")) {
|
|
toasts.error("Please select an image file.");
|
|
return;
|
|
}
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toasts.error("Image must be under 2MB.");
|
|
return;
|
|
}
|
|
|
|
isUploading = true;
|
|
try {
|
|
const ext = file.name.split(".").pop() || "png";
|
|
const path = `org-avatars/${org.id}.${ext}`;
|
|
|
|
const { error: uploadError } = await supabase.storage
|
|
.from("avatars")
|
|
.upload(path, file, { upsert: true });
|
|
|
|
if (uploadError) {
|
|
toasts.error("Failed to upload avatar.");
|
|
return;
|
|
}
|
|
|
|
const { data: urlData } = supabase.storage
|
|
.from("avatars")
|
|
.getPublicUrl(path);
|
|
|
|
const publicUrl = `${urlData.publicUrl}?t=${Date.now()}`;
|
|
|
|
const { error: dbError } = await supabase
|
|
.from("organizations")
|
|
.update({ avatar_url: publicUrl })
|
|
.eq("id", org.id);
|
|
|
|
if (dbError) {
|
|
toasts.error("Failed to save avatar URL.");
|
|
return;
|
|
}
|
|
|
|
avatarUrl = publicUrl;
|
|
await invalidateAll();
|
|
toasts.success("Avatar updated.");
|
|
} catch (err) {
|
|
toasts.error("Avatar upload failed.");
|
|
} finally {
|
|
isUploading = false;
|
|
input.value = "";
|
|
}
|
|
}
|
|
|
|
async function removeAvatar() {
|
|
isSaving = true;
|
|
const { error } = await supabase
|
|
.from("organizations")
|
|
.update({ avatar_url: null })
|
|
.eq("id", org.id);
|
|
|
|
if (error) {
|
|
toasts.error("Failed to remove avatar.");
|
|
} else {
|
|
avatarUrl = null;
|
|
await invalidateAll();
|
|
toasts.success("Avatar removed.");
|
|
}
|
|
isSaving = false;
|
|
}
|
|
|
|
async function saveGeneralSettings() {
|
|
isSaving = true;
|
|
const { error } = await supabase
|
|
.from("organizations")
|
|
.update({ name: orgName, slug: orgSlug })
|
|
.eq("id", org.id);
|
|
|
|
if (error) {
|
|
toasts.error("Failed to save settings.");
|
|
} else if (orgSlug !== org.slug) {
|
|
window.location.href = `/${orgSlug}/settings`;
|
|
} else {
|
|
toasts.success("Settings saved.");
|
|
}
|
|
isSaving = false;
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-6 max-w-2xl">
|
|
<!-- Organization Details -->
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
|
<h2 class="font-heading text-body text-white">Organization details</h2>
|
|
|
|
<!-- Avatar Upload -->
|
|
<div class="flex flex-col gap-2">
|
|
<span class="font-body text-body-sm text-light/60">Avatar</span>
|
|
<div class="flex items-center gap-4">
|
|
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
class="hidden"
|
|
bind:this={avatarInput}
|
|
onchange={handleAvatarUpload}
|
|
/>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onclick={() => avatarInput?.click()}
|
|
loading={isUploading}
|
|
>
|
|
Upload
|
|
</Button>
|
|
{#if avatarUrl}
|
|
<Button
|
|
variant="tertiary"
|
|
size="sm"
|
|
onclick={removeAvatar}
|
|
>
|
|
Remove
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
label="Name"
|
|
bind:value={orgName}
|
|
placeholder="Organization name"
|
|
/>
|
|
<Input
|
|
label="URL slug (yoursite.com/...)"
|
|
bind:value={orgSlug}
|
|
placeholder="my-org"
|
|
/>
|
|
<div>
|
|
<Button size="sm" onclick={saveGeneralSettings} loading={isSaving}
|
|
>Save Changes</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Danger Zone -->
|
|
{#if isOwner}
|
|
<div class="bg-dark/30 border border-error/10 rounded-xl p-5 flex flex-col gap-3">
|
|
<h4 class="font-heading text-body-sm text-error">Danger Zone</h4>
|
|
<p class="font-body text-[11px] text-light/40">
|
|
Permanently delete this organization and all its data.
|
|
</p>
|
|
<div>
|
|
<Button variant="danger" size="sm" onclick={onDelete}
|
|
>Delete Organization</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Leave Organization (non-owners) -->
|
|
{#if !isOwner}
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-3">
|
|
<h4 class="font-heading text-body-sm text-white">Leave Organization</h4>
|
|
<p class="font-body text-[11px] text-light/40">
|
|
Leave this organization. You will need to be re-invited to rejoin.
|
|
</p>
|
|
<div>
|
|
<Button variant="secondary" size="sm" onclick={onLeave}
|
|
>Leave {org.name}</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|