Add org settings layout with responsive nav and member management
- Create SettingsLayout component with left sidebar nav (desktop) and horizontal scroll nav (mobile) - Add settings gear icon to sidebar (Lucide icon, only in org context) - Fix home icon highlighting to only match exact org home path - Create /settings/members route with full member management - Create /settings/sites placeholder route - Update general settings to use new SettingsLayout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from "@lucide/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { cn } from "$lib/utils.js";
|
||||
@@ -68,8 +69,10 @@ const navItems = $derived.by(() => {
|
||||
<nav class="flex flex-1 flex-col items-center gap-3">
|
||||
{#each navItems as item}
|
||||
{@const isActive =
|
||||
$page.url.pathname === item.href ||
|
||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
||||
item.icon === "home"
|
||||
? $page.url.pathname === item.href
|
||||
: $page.url.pathname === item.href ||
|
||||
$page.url.pathname.startsWith(item.href + "/")}
|
||||
<a
|
||||
href={item.href}
|
||||
class={cn(
|
||||
@@ -153,8 +156,34 @@ const navItems = $derived.by(() => {
|
||||
|
||||
</nav>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="flex h-[80px] items-center justify-center">
|
||||
<!-- Bottom section -->
|
||||
<div class="flex flex-col items-center gap-3 pb-6">
|
||||
<!-- Settings (only in org context) -->
|
||||
{#if currentSlug}
|
||||
{@const isSettingsActive = $page.url.pathname.startsWith(`/dashboard/${currentSlug}/settings`)}
|
||||
<a
|
||||
href="/dashboard/{currentSlug}/settings"
|
||||
class={cn(
|
||||
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
|
||||
isSettingsActive
|
||||
? "bg-sidebar-accent text-sidebar-foreground"
|
||||
: "text-sidebar-muted hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||
)}
|
||||
aria-label="Settings"
|
||||
aria-current={isSettingsActive ? "page" : undefined}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
|
||||
<!-- Tooltip -->
|
||||
<span
|
||||
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
|
||||
>
|
||||
Settings
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- User Menu -->
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Dashboard layout components
|
||||
|
||||
// Admin layout components
|
||||
export {
|
||||
AdminHeader,
|
||||
@@ -16,3 +14,6 @@ export {
|
||||
OrgSwitcher,
|
||||
UserMenu,
|
||||
} from "./dashboard/index.js";
|
||||
|
||||
// Settings layout components
|
||||
export { SettingsLayout } from "./settings/index.js";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SettingsLayout } from "./settings-layout.svelte";
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { Building2, Globe, Settings, Users } from "@lucide/svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props();
|
||||
|
||||
// Get org context from parent layout
|
||||
const orgContext = getContext<{ slug: string }>("orgContext");
|
||||
const slug = $derived(orgContext?.slug);
|
||||
|
||||
// Settings navigation items
|
||||
const navItems = $derived.by(() => [
|
||||
{
|
||||
href: `/dashboard/${slug}/settings`,
|
||||
icon: Settings,
|
||||
label: "General",
|
||||
description: "Organization name, logo, and preferences",
|
||||
},
|
||||
{
|
||||
href: `/dashboard/${slug}/settings/members`,
|
||||
icon: Users,
|
||||
label: "Members",
|
||||
description: "Manage team members and invitations",
|
||||
},
|
||||
{
|
||||
href: `/dashboard/${slug}/settings/sites`,
|
||||
icon: Globe,
|
||||
label: "Sites",
|
||||
description: "Connected websites and domains",
|
||||
},
|
||||
]);
|
||||
|
||||
// Determine active item
|
||||
const activeHref = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
// Exact match for base settings path
|
||||
if (href === `/dashboard/${slug}/settings`) {
|
||||
return activeHref === href;
|
||||
}
|
||||
// Prefix match for sub-pages
|
||||
return activeHref.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DashboardLayout title={title}>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- Settings Navigation -->
|
||||
<nav class="w-full shrink-0 lg:w-64">
|
||||
<!-- Mobile: horizontal scroll -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class={cn(
|
||||
"flex shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-4 w-4" />
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: vertical list -->
|
||||
<div class="hidden space-y-1 lg:block">
|
||||
{#each navItems as item}
|
||||
{@const active = isActive(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class={cn(
|
||||
"group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||
active
|
||||
? "bg-primary/5 text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-muted-foreground/20",
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-0.5">
|
||||
<p class="text-sm font-medium">{item.label}</p>
|
||||
<p class="text-xs text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
@@ -12,7 +12,7 @@ import { getContext } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import { DashboardLayout } from "$lib/components/layout";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog } from "$lib/components/org";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
@@ -175,7 +175,7 @@ async function executeConfirmAction() {
|
||||
<title>Settings | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organization Settings">
|
||||
<SettingsLayout title="Settings">
|
||||
{#if isLoading || orgQuery.isPending}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -192,7 +192,7 @@ async function executeConfirmAction() {
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="space-y-6">
|
||||
<!-- General Settings (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
@@ -295,18 +295,9 @@ async function executeConfirmAction() {
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="pt-4">
|
||||
<a
|
||||
href="/dashboard/{slug}"
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to organization
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
</SettingsLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { getContext } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { api } from "$lib/api/client";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import { ConfirmDialog, RoleBadge } from "$lib/components/org";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "$lib/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "$lib/components/ui/table";
|
||||
|
||||
/**
|
||||
* Members management settings page
|
||||
*/
|
||||
|
||||
// Types from API contract
|
||||
type OrgMemberOutput = Awaited<
|
||||
ReturnType<typeof api.orgs.members.list>
|
||||
>[number];
|
||||
type OrgInviteOutput = Awaited<
|
||||
ReturnType<typeof api.orgs.invites.list>
|
||||
>[number];
|
||||
type UserProfile = Awaited<ReturnType<typeof api.me.get>>;
|
||||
|
||||
// Get org context from layout
|
||||
const orgContext = getContext<{
|
||||
slug: string;
|
||||
userQuery: { data: UserProfile | undefined };
|
||||
membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean };
|
||||
currentUserRole: "owner" | "admin" | "member" | null;
|
||||
canManageOrg: boolean;
|
||||
isOwner: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}>("orgContext");
|
||||
|
||||
const slug = $derived(orgContext.slug);
|
||||
const userData = $derived(orgContext.userQuery.data);
|
||||
const membersData = $derived(orgContext.membersQuery.data);
|
||||
const currentUserRole = $derived(orgContext.currentUserRole);
|
||||
const canManageOrg = $derived(orgContext.canManageOrg);
|
||||
const isOwner = $derived(orgContext.isOwner);
|
||||
const isLoading = $derived(orgContext.isLoading);
|
||||
const error = $derived(orgContext.error);
|
||||
const currentUserId = $derived(userData?.id);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch invites (only for admins+)
|
||||
const invitesQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
queryFn: () => api.orgs.invites.list({ slug }),
|
||||
enabled: !!slug && canManageOrg,
|
||||
}));
|
||||
|
||||
// Invite form state
|
||||
let inviteEmail = $state("");
|
||||
let inviteRole = $state<"member" | "admin" | "owner">("member");
|
||||
let isInviting = $state(false);
|
||||
|
||||
// Confirmation dialog state
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmDialogTitle = $state("");
|
||||
let confirmDialogDescription = $state("");
|
||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||
let isConfirmLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Send invite to email
|
||||
*/
|
||||
async function handleInvite() {
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error("Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
isInviting = true;
|
||||
try {
|
||||
await api.orgs.invites.create({
|
||||
slug,
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
});
|
||||
toast.success("Invitation sent!");
|
||||
inviteEmail = "";
|
||||
inviteRole = "member";
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to send invitation");
|
||||
} finally {
|
||||
isInviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
*/
|
||||
async function handleCancelInvite(inviteId: number, email: string) {
|
||||
confirmDialogTitle = "Cancel Invitation";
|
||||
confirmDialogDescription = `Are you sure you want to cancel the invitation to ${email}?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.invites.cancel({ slug, inviteId });
|
||||
toast.success("Invitation cancelled");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to cancel invitation",
|
||||
);
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async function handleUpdateRole(
|
||||
userId: number,
|
||||
newRole: "owner" | "admin" | "member",
|
||||
) {
|
||||
try {
|
||||
await api.orgs.members.updateRole({ slug, userId, role: newRole });
|
||||
toast.success("Role updated");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update role");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member
|
||||
*/
|
||||
async function handleRemoveMember(
|
||||
userId: number,
|
||||
displayName: string | null,
|
||||
email: string,
|
||||
) {
|
||||
confirmDialogTitle = "Remove Member";
|
||||
confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.members.remove({ slug, userId });
|
||||
toast.success("Member removed");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "members"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute confirm action
|
||||
*/
|
||||
async function executeConfirmAction() {
|
||||
isConfirmLoading = true;
|
||||
try {
|
||||
await confirmAction();
|
||||
confirmDialogOpen = false;
|
||||
} finally {
|
||||
isConfirmLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return "Expired";
|
||||
if (days === 0) return "Today";
|
||||
if (days === 1) return "Tomorrow";
|
||||
return `${days} days`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can remove a member
|
||||
*/
|
||||
function canRemoveMember(memberRole: string, memberId: number): boolean {
|
||||
if (memberId === currentUserId) return false;
|
||||
if (isOwner) return true;
|
||||
if (currentUserRole === "admin" && memberRole === "member") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles for invite based on current user's role
|
||||
*/
|
||||
const availableInviteRoles = $derived.by(() => {
|
||||
if (isOwner) return ["member", "admin", "owner"] as const;
|
||||
if (currentUserRole === "admin") return ["member", "admin"] as const;
|
||||
return ["member"] as const;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Members | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<SettingsLayout title="Settings">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading members...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load members"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Invite form (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<UserPlus class="h-4 w-4" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Label for="invite-email">Email address</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="colleague@example.com"
|
||||
bind:value={inviteEmail}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full space-y-2 sm:w-32">
|
||||
<Label for="invite-role">Role</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={inviteRole}
|
||||
onValueChange={(v) => { if (v) inviteRole = v as typeof inviteRole; }}
|
||||
disabled={isInviting}
|
||||
>
|
||||
<SelectTrigger id="invite-role" class="w-full">
|
||||
{inviteRole.charAt(0).toUpperCase() + inviteRole.slice(1)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each availableInviteRoles as role}
|
||||
<SelectItem value={role} label={role.charAt(0).toUpperCase() + role.slice(1)} />
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isInviting || !inviteEmail.trim()}>
|
||||
{#if isInviting}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Send Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Pending invites (admin+ only) -->
|
||||
{#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Clock class="h-4 w-4" />
|
||||
Pending Invitations ({invitesQuery.data.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Invited by</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead class="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each invitesQuery.data as invite (invite.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">{invite.email}</TableCell>
|
||||
<TableCell><RoleBadge role={invite.role} /></TableCell>
|
||||
<TableCell class="text-muted-foreground">{invite.invitedBy}</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{formatRelativeTime(new Date(invite.expiresAt))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => handleCancelInvite(invite.id, invite.email)}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Cancel</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Members list -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Users class="h-4 w-4" />
|
||||
Members ({membersData?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if membersData && membersData.length > 0}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{#if canManageOrg}
|
||||
<TableHead class="w-[100px]"></TableHead>
|
||||
{/if}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each membersData as member (member.id)}
|
||||
{@const isCurrentUser = member.userId === currentUserId}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xs font-medium">
|
||||
{(member.displayName || member.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{member.displayName || member.email}
|
||||
{#if isCurrentUser}
|
||||
<span class="ml-1 text-xs text-muted-foreground">(You)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if member.displayName}
|
||||
<p class="text-xs text-muted-foreground">{member.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if isOwner && !isCurrentUser}
|
||||
<Select
|
||||
type="single"
|
||||
value={member.role}
|
||||
onValueChange={(v) => { if (v) handleUpdateRole(member.userId, v as "owner" | "admin" | "member"); }}
|
||||
>
|
||||
<SelectTrigger size="sm" class="h-7 w-24 text-xs">
|
||||
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member" label="Member" />
|
||||
<SelectItem value="admin" label="Admin" />
|
||||
<SelectItem value="owner" label="Owner" />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{:else}
|
||||
<RoleBadge role={member.role} />
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{new Date(member.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{#if canManageOrg}
|
||||
<TableCell>
|
||||
{#if canRemoveMember(member.role, member.userId)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => handleRemoveMember(member.userId, member.displayName, member.email)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No members yet</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingsLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title={confirmDialogTitle}
|
||||
description={confirmDialogDescription}
|
||||
variant={confirmDialogVariant}
|
||||
loading={isConfirmLoading}
|
||||
onconfirm={executeConfirmAction}
|
||||
oncancel={() => confirmDialogOpen = false}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Globe } from "@lucide/svelte";
|
||||
import { SettingsLayout } from "$lib/components/layout";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sites | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<SettingsLayout title="Settings">
|
||||
<Card class="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<Globe class="h-5 w-5 text-muted-foreground" />
|
||||
Sites
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your connected websites and domains.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Globe class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-sm font-medium">Coming Soon</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Site management features are currently in development.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsLayout>
|
||||
Reference in New Issue
Block a user