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">
|
<script lang="ts">
|
||||||
|
import { Settings } from "@lucide/svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { cn } from "$lib/utils.js";
|
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">
|
<nav class="flex flex-1 flex-col items-center gap-3">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const isActive =
|
{@const isActive =
|
||||||
$page.url.pathname === item.href ||
|
item.icon === "home"
|
||||||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
? $page.url.pathname === item.href
|
||||||
|
: $page.url.pathname === item.href ||
|
||||||
|
$page.url.pathname.startsWith(item.href + "/")}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -153,8 +156,34 @@ const navItems = $derived.by(() => {
|
|||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- User Menu -->
|
||||||
<div class="flex h-[80px] items-center justify-center">
|
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// Dashboard layout components
|
|
||||||
|
|
||||||
// Admin layout components
|
// Admin layout components
|
||||||
export {
|
export {
|
||||||
AdminHeader,
|
AdminHeader,
|
||||||
@@ -16,3 +14,6 @@ export {
|
|||||||
OrgSwitcher,
|
OrgSwitcher,
|
||||||
UserMenu,
|
UserMenu,
|
||||||
} from "./dashboard/index.js";
|
} 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 { toast } from "svelte-sonner";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { api } from "$lib/api/client";
|
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 { ConfirmDialog } from "$lib/components/org";
|
||||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -175,7 +175,7 @@ async function executeConfirmAction() {
|
|||||||
<title>Settings | Publisher Dashboard</title>
|
<title>Settings | Publisher Dashboard</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<DashboardLayout title="Organization Settings">
|
<SettingsLayout title="Settings">
|
||||||
{#if isLoading || orgQuery.isPending}
|
{#if isLoading || orgQuery.isPending}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -192,7 +192,7 @@ async function executeConfirmAction() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mx-auto max-w-2xl space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- General Settings (admin+ only) -->
|
<!-- General Settings (admin+ only) -->
|
||||||
{#if canManageOrg}
|
{#if canManageOrg}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -295,18 +295,9 @@ async function executeConfirmAction() {
|
|||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</DashboardLayout>
|
</SettingsLayout>
|
||||||
|
|
||||||
<!-- Confirmation dialog -->
|
<!-- Confirmation dialog -->
|
||||||
<ConfirmDialog
|
<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