ALL 0.1.0 Code
This commit is contained in:
306
src/routes/(app)/projects/+page.svelte
Normal file
306
src/routes/(app)/projects/+page.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { projects as projectsApi, teams as teamsApi } from "$lib/api";
|
||||
import type { Project, Team } from "$lib/types/api";
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let teams = $state<Team[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state("");
|
||||
|
||||
let showModal = $state(false);
|
||||
let newName = $state("");
|
||||
let newDesc = $state("");
|
||||
let selectedTeamId = $state("");
|
||||
let creating = $state(false);
|
||||
let createError = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [p, t] = await Promise.all([projectsApi.list(), teamsApi.list()]);
|
||||
projects = p;
|
||||
teams = t;
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : "Failed to load projects";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function openModal() {
|
||||
newName = "";
|
||||
newDesc = "";
|
||||
selectedTeamId = "";
|
||||
createError = "";
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!newName.trim()) {
|
||||
createError = "Project name is required.";
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
createError = "";
|
||||
try {
|
||||
let project: Project;
|
||||
if (selectedTeamId) {
|
||||
project = await teamsApi.createProject(
|
||||
selectedTeamId,
|
||||
newName.trim(),
|
||||
newDesc.trim(),
|
||||
);
|
||||
} else {
|
||||
project = await projectsApi.createPersonal(
|
||||
newName.trim(),
|
||||
newDesc.trim(),
|
||||
);
|
||||
}
|
||||
projects = [project, ...projects];
|
||||
showModal = false;
|
||||
} catch (e: unknown) {
|
||||
createError =
|
||||
e instanceof Error ? e.message : "Failed to create project.";
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(p: Project): string {
|
||||
return p.is_archived ? "Archived" : "Active";
|
||||
}
|
||||
|
||||
function statusClass(p: Project): string {
|
||||
return p.is_archived
|
||||
? "bg-neutral-700 text-neutral-400"
|
||||
: "bg-blue-900/50 text-blue-300";
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Projects — FPMB</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View and manage all your personal and team projects in FPMB."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-white">Projects</h1>
|
||||
<button
|
||||
onclick={openModal}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="w-4 h-4" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-neutral-500 text-sm">Loading...</p>
|
||||
{:else if error}
|
||||
<p class="text-red-400 text-sm">{error}</p>
|
||||
{:else if projects.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full bg-neutral-800 flex items-center justify-center mb-4 border border-neutral-700"
|
||||
>
|
||||
<Icon icon="lucide:folder-open" class="w-8 h-8 text-neutral-500" />
|
||||
</div>
|
||||
<h2 class="text-white font-semibold text-lg mb-1">No projects yet</h2>
|
||||
<p class="text-neutral-500 text-sm mb-6">
|
||||
Create your first project to get started.
|
||||
</p>
|
||||
<button
|
||||
onclick={openModal}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="w-4 h-4" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each projects as project (project.id)}
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 hover:border-blue-500 transition-colors shadow-sm group flex flex-col"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<a
|
||||
href="/board/{project.id}"
|
||||
class="text-xl font-semibold text-white group-hover:text-blue-400 transition-colors leading-tight"
|
||||
>{project.name}</a
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ml-2 shrink-0 {statusClass(
|
||||
project,
|
||||
)}"
|
||||
>
|
||||
{statusLabel(project)}
|
||||
</span>
|
||||
</div>
|
||||
{#if project.team_name}
|
||||
<p class="text-xs text-neutral-500 mb-2 flex items-center gap-1">
|
||||
<Icon icon="lucide:users" class="w-3 h-3" />
|
||||
{project.team_name}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-neutral-400 text-sm mb-6 line-clamp-2 flex-1">
|
||||
{project.description || "No description"}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="text-xs text-neutral-500">
|
||||
Updated {new Date(project.updated_at).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "2-digit", day: "2-digit", year: "numeric" },
|
||||
)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/projects/{project.id}/calendar"
|
||||
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
|
||||
title="Calendar"
|
||||
>
|
||||
<Icon icon="lucide:calendar" class="w-4 h-4" />
|
||||
</a>
|
||||
{#if !project.team_name}
|
||||
<a
|
||||
href="/projects/{project.id}/files"
|
||||
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
|
||||
title="Files"
|
||||
>
|
||||
<Icon icon="lucide:paperclip" class="w-4 h-4" />
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/projects/{project.id}/settings"
|
||||
class="text-neutral-400 hover:text-white text-xs flex items-center gap-1"
|
||||
title="Settings"
|
||||
>
|
||||
<Icon icon="lucide:settings" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-white">New Project</h2>
|
||||
<button
|
||||
onclick={closeModal}
|
||||
class="text-neutral-400 hover:text-white transition-colors p-1 rounded"
|
||||
>
|
||||
<Icon icon="lucide:x" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="proj-name"
|
||||
class="block text-sm font-medium text-neutral-300 mb-1.5"
|
||||
>Project name</label
|
||||
>
|
||||
<input
|
||||
id="proj-name"
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="e.g. Website Redesign"
|
||||
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="proj-desc"
|
||||
class="block text-sm font-medium text-neutral-300 mb-1.5"
|
||||
>Description <span class="text-neutral-500 font-normal"
|
||||
>(optional)</span
|
||||
></label
|
||||
>
|
||||
<textarea
|
||||
id="proj-desc"
|
||||
bind:value={newDesc}
|
||||
placeholder="What is this project about?"
|
||||
rows="3"
|
||||
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="proj-team"
|
||||
class="block text-sm font-medium text-neutral-300 mb-1.5"
|
||||
>Team <span class="text-neutral-500 font-normal">(optional)</span
|
||||
></label
|
||||
>
|
||||
<select
|
||||
id="proj-team"
|
||||
bind:value={selectedTeamId}
|
||||
class="w-full bg-neutral-900 border border-neutral-600 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Personal (no team)</option>
|
||||
{#each teams as team (team.id)}
|
||||
<option value={team.id}>{team.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-neutral-500 mt-1">
|
||||
Personal projects are only visible to you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
<p class="text-red-400 text-sm">{createError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onclick={closeModal}
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={submitCreate}
|
||||
disabled={creating}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2"
|
||||
>
|
||||
{#if creating}
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
><circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle><path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v8z"
|
||||
></path></svg
|
||||
>
|
||||
{/if}
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
215
src/routes/(app)/projects/[id]/calendar/+page.svelte
Normal file
215
src/routes/(app)/projects/[id]/calendar/+page.svelte
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import Calendar from "$lib/components/Calendar/Calendar.svelte";
|
||||
import { projects as projectsApi, events as eventsApi } from "$lib/api";
|
||||
import type { Event } from "$lib/types/api";
|
||||
|
||||
let projectId = $derived($page.params.id ?? "");
|
||||
|
||||
let rawEvents = $state<Event[]>([]);
|
||||
let loading = $state(true);
|
||||
let isModalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let newEvent = $state({ title: "", description: "", date: "", time: "" });
|
||||
|
||||
let calendarEvents = $derived(
|
||||
rawEvents.map((e) => {
|
||||
const dt = new Date(e.start_time);
|
||||
const hours = dt.getHours();
|
||||
const minutes = dt.getMinutes().toString().padStart(2, "0");
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
const h = hours % 12 || 12;
|
||||
return {
|
||||
id: e.id,
|
||||
date: e.start_time.split("T")[0],
|
||||
title: e.title,
|
||||
time: `${h}:${minutes} ${ampm}`,
|
||||
color: "blue",
|
||||
description: e.description,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
rawEvents = await projectsApi.listEvents(projectId);
|
||||
} catch {
|
||||
rawEvents = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function addEvent(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newEvent.title || !newEvent.date) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const startTime = newEvent.time
|
||||
? new Date(`${newEvent.date}T${newEvent.time}`).toISOString()
|
||||
: new Date(`${newEvent.date}T00:00:00`).toISOString();
|
||||
const created = await projectsApi.createEvent(projectId, {
|
||||
title: newEvent.title,
|
||||
description: newEvent.description,
|
||||
start_time: startTime,
|
||||
end_time: startTime,
|
||||
});
|
||||
rawEvents = [...rawEvents, created];
|
||||
isModalOpen = false;
|
||||
newEvent = { title: "", description: "", date: "", time: "" };
|
||||
} catch {
|
||||
error = "Failed to create event.";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Project Calendar — FPMB</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View and manage events and milestones for this project's calendar in FPMB."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col -m-6 p-6">
|
||||
<header
|
||||
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
href="/projects"
|
||||
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||
>
|
||||
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
|
||||
Project Calendar
|
||||
</h1>
|
||||
<div class="text-sm text-neutral-400 flex items-center space-x-2 mt-1">
|
||||
<a
|
||||
href="/projects/{projectId}/calendar"
|
||||
class="hover:text-blue-400 transition-colors">Overview</a
|
||||
>
|
||||
<span>/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
onclick={() => (isModalOpen = true)}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="w-4 h-4 mr-2" />
|
||||
Add Event
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center text-neutral-400">
|
||||
Loading events...
|
||||
</div>
|
||||
{:else}
|
||||
<Calendar events={calendarEvents} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isModalOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm"
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0" onclick={() => (isModalOpen = false)}></div>
|
||||
<div
|
||||
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-md"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-neutral-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-white">Add Event</h2>
|
||||
<button
|
||||
onclick={() => (isModalOpen = false)}
|
||||
class="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-700"
|
||||
>
|
||||
<Icon icon="lucide:x" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onsubmit={addEvent} class="p-6 space-y-4">
|
||||
{#if error}
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newEvent.title}
|
||||
required
|
||||
placeholder="Event title"
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={newEvent.description}
|
||||
rows="2"
|
||||
placeholder="Optional description"
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Date</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={newEvent.date}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Time</label
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
bind:value={newEvent.time}
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isModalOpen = false)}
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Event"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
391
src/routes/(app)/projects/[id]/files/+page.svelte
Normal file
391
src/routes/(app)/projects/[id]/files/+page.svelte
Normal file
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { projects as projectsApi, files as filesApi } from "$lib/api";
|
||||
import type { FileItem } from "$lib/types/api";
|
||||
import FileViewer from "$lib/components/FileViewer/FileViewer.svelte";
|
||||
|
||||
let projectId = $derived($page.params.id ?? "");
|
||||
|
||||
let folderStack = $state<{ id: string; name: string }[]>([]);
|
||||
let currentParentId = $derived(
|
||||
folderStack.length > 0 ? folderStack[folderStack.length - 1].id : "",
|
||||
);
|
||||
|
||||
let fileList = $state<FileItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let folderName = $state("");
|
||||
let showFolderInput = $state(false);
|
||||
let savingFolder = $state(false);
|
||||
|
||||
async function loadFiles(parentId: string) {
|
||||
loading = true;
|
||||
try {
|
||||
fileList = await projectsApi.listFiles(projectId, parentId);
|
||||
} catch {
|
||||
fileList = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadFiles(currentParentId);
|
||||
});
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return "--";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
async function createFolder(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!folderName.trim()) return;
|
||||
savingFolder = true;
|
||||
try {
|
||||
const created = await projectsApi.createFolder(
|
||||
projectId,
|
||||
folderName.trim(),
|
||||
currentParentId,
|
||||
);
|
||||
fileList = [created, ...fileList];
|
||||
folderName = "";
|
||||
showFolderInput = false;
|
||||
} catch {
|
||||
} finally {
|
||||
savingFolder = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const created = await projectsApi.uploadFile(
|
||||
projectId,
|
||||
file,
|
||||
currentParentId,
|
||||
);
|
||||
fileList = [...fileList, created];
|
||||
} catch {}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function deleteFile(id: string) {
|
||||
if (!confirm("Delete this item?")) return;
|
||||
try {
|
||||
await filesApi.delete(id);
|
||||
fileList = fileList.filter((f) => f.id !== id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openFolder(folder: FileItem) {
|
||||
folderStack = [...folderStack, { id: folder.id, name: folder.name }];
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index: number) {
|
||||
if (index === -1) {
|
||||
folderStack = [];
|
||||
} else {
|
||||
folderStack = folderStack.slice(0, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(type: string) {
|
||||
if (type === "folder") {
|
||||
return `<svg class="w-6 h-6 text-blue-400" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>`;
|
||||
}
|
||||
return `<svg class="w-6 h-6 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>`;
|
||||
}
|
||||
|
||||
let viewingFile = $state<FileItem | null>(null);
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Project Files — FPMB</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse, upload, and organise files and folders for this project in FPMB."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full flex flex-col -m-6 p-6 overflow-hidden">
|
||||
<header
|
||||
class="flex flex-col md:flex-row md:items-center justify-between mb-6 pb-6 border-b border-neutral-700 shrink-0 gap-4"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
href="/projects"
|
||||
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
|
||||
Project Files
|
||||
</h1>
|
||||
<div
|
||||
class="text-sm text-neutral-400 flex items-center space-x-2 mt-1 flex-wrap gap-y-1"
|
||||
>
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(-1)}
|
||||
class="hover:text-blue-400 transition-colors">Root</button
|
||||
>
|
||||
{#each folderStack as crumb, i}
|
||||
<span>/</span>
|
||||
<button
|
||||
onclick={() => navigateToBreadcrumb(i)}
|
||||
class="hover:text-blue-400 transition-colors">{crumb.name}</button
|
||||
>
|
||||
{/each}
|
||||
<span>/</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
onclick={() => (showFolderInput = !showFolderInput)}
|
||||
class="bg-neutral-800 hover:bg-neutral-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
></path></svg
|
||||
>
|
||||
New Folder
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
class="hidden"
|
||||
onchange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
onclick={() => fileInput.click()}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
></path></svg
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showFolderInput}
|
||||
<form onsubmit={createFolder} class="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={folderName}
|
||||
placeholder="Folder name"
|
||||
required
|
||||
autofocus
|
||||
class="flex-1 px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingFolder}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingFolder ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showFolderInput = false)}
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-auto bg-neutral-800 rounded-lg shadow-sm border border-neutral-700"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="p-12 text-center text-neutral-400">Loading files...</div>
|
||||
{:else}
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-neutral-850 border-b border-neutral-700">
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-32 hidden sm:table-cell"
|
||||
>Size</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider w-40 hidden md:table-cell"
|
||||
>Last Modified</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right w-24"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-700">
|
||||
{#each fileList as file (file.id)}
|
||||
<tr
|
||||
class="hover:bg-neutral-750 transition-colors group cursor-pointer"
|
||||
ondblclick={() =>
|
||||
file.type === "folder"
|
||||
? openFolder(file)
|
||||
: (viewingFile = file)}
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="shrink-0 flex items-center justify-center">
|
||||
{@html getIcon(file.type)}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
{#if file.type === "folder"}
|
||||
<button
|
||||
onclick={() => openFolder(file)}
|
||||
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors text-left"
|
||||
>{file.name}</button
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs text-neutral-500 sm:hidden mt-1">
|
||||
{formatSize(file.size_bytes)} • {formatDate(
|
||||
file.updated_at,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden sm:table-cell"
|
||||
>
|
||||
{formatSize(file.size_bytes)}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell"
|
||||
>
|
||||
{formatDate(file.updated_at)}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-end space-x-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{#if file.type === "file" && file.storage_url}
|
||||
<button
|
||||
onclick={() => filesApi.download(file.id, file.name)}
|
||||
class="text-neutral-400 hover:text-white p-1 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => deleteFile(file.id)}
|
||||
class="text-neutral-400 hover:text-red-400 p-1 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
{#if fileList.length === 0}
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-12 text-center text-neutral-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg
|
||||
class="w-12 h-12 text-neutral-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
></path></svg
|
||||
>
|
||||
<p>This folder is empty.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileViewer bind:file={viewingFile} downloadUrl={filesApi.downloadUrl} />
|
||||
234
src/routes/(app)/projects/[id]/settings/+page.svelte
Normal file
234
src/routes/(app)/projects/[id]/settings/+page.svelte
Normal file
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { projects as projectsApi } from "$lib/api";
|
||||
import type { Project } from "$lib/types/api";
|
||||
|
||||
let projectId = $derived($page.params.id ?? "");
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let saveError = $state("");
|
||||
let saveSuccess = $state(false);
|
||||
|
||||
let projectName = $state("");
|
||||
let projectDescription = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
project = await projectsApi.get(projectId);
|
||||
projectName = project.name;
|
||||
projectDescription = project.description;
|
||||
} catch {
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function saveSettings(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
saving = true;
|
||||
saveError = "";
|
||||
saveSuccess = false;
|
||||
try {
|
||||
project = await projectsApi.update(projectId, {
|
||||
name: projectName,
|
||||
description: projectDescription,
|
||||
});
|
||||
saveSuccess = true;
|
||||
setTimeout(() => (saveSuccess = false), 3000);
|
||||
} catch (err: unknown) {
|
||||
saveError =
|
||||
err instanceof Error ? err.message : "Failed to save changes.";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveProject() {
|
||||
if (!confirm("Archive this project? It will become read-only.")) return;
|
||||
try {
|
||||
await projectsApi.archive(projectId);
|
||||
goto("/projects");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (
|
||||
!confirm(
|
||||
"Permanently delete this project and all its data? This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await projectsApi.delete(projectId);
|
||||
goto("/projects");
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title
|
||||
>{projectName
|
||||
? `${projectName} Settings — FPMB`
|
||||
: "Project Settings — FPMB"}</title
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content="Configure project name, description, archive state, and danger zone settings in FPMB."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-10">
|
||||
<div class="flex items-center space-x-4 mb-2">
|
||||
<a
|
||||
href="/projects"
|
||||
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
Project Settings
|
||||
</h1>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
Configure {projectName || "..."} preferences and access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-neutral-700 mb-8">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<a
|
||||
href="/projects/{projectId}/settings"
|
||||
class="border-blue-500 text-blue-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
||||
>
|
||||
General Settings
|
||||
</a>
|
||||
<a
|
||||
href="/projects/{projectId}/webhooks"
|
||||
class="border-transparent text-neutral-400 hover:text-white hover:border-neutral-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
Webhooks & Integrations
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-neutral-400 py-12 text-center">Loading...</div>
|
||||
{:else}
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-neutral-700">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">General Info</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Update project name and description.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={saveSettings} class="p-6 space-y-6">
|
||||
{#if saveError}
|
||||
<p class="text-sm text-red-400">{saveError}</p>
|
||||
{/if}
|
||||
{#if saveSuccess}
|
||||
<p class="text-sm text-green-400">Changes saved.</p>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="projectName"
|
||||
class="block text-sm font-medium text-neutral-300"
|
||||
>Project Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
bind:value={projectName}
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="projectDescription"
|
||||
class="block text-sm font-medium text-neutral-300"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
id="projectDescription"
|
||||
bind:value={projectDescription}
|
||||
rows="3"
|
||||
class="mt-1 block w-full px-3 py-2 border border-neutral-600 rounded-md shadow-sm placeholder-neutral-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-neutral-700 text-white resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-neutral-700 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-md shadow-sm border border-transparent transition-colors text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-red-900 overflow-hidden"
|
||||
>
|
||||
<div class="p-6 border-b border-red-900 bg-red-900/10">
|
||||
<h2 class="text-xl font-semibold text-red-500 mb-1">Danger Zone</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Irreversible destructive actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-white">Archive Project</h3>
|
||||
<p class="text-xs text-neutral-400 mt-1">
|
||||
Mark this project as read-only and hide it from the active lists.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={archiveProject}
|
||||
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-neutral-600 transition-colors text-sm"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-t border-neutral-700 pt-6 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-red-400">Delete Project</h3>
|
||||
<p class="text-xs text-neutral-400 mt-1">
|
||||
Permanently remove this project, its boards, files, and all
|
||||
associated data.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={deleteProject}
|
||||
class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm"
|
||||
>
|
||||
Delete Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
372
src/routes/(app)/projects/[id]/webhooks/+page.svelte
Normal file
372
src/routes/(app)/projects/[id]/webhooks/+page.svelte
Normal file
@@ -0,0 +1,372 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { projects as projectsApi, webhooks as webhooksApi } from "$lib/api";
|
||||
import type { Webhook } from "$lib/types/api";
|
||||
|
||||
let projectId = $derived($page.params.id ?? "");
|
||||
|
||||
let webhookList = $state<Webhook[]>([]);
|
||||
let loading = $state(true);
|
||||
let isModalOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let newWebhook = $state({ name: "", type: "discord", url: "", secret: "" });
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
webhookList = await projectsApi.listWebhooks(projectId);
|
||||
} catch {
|
||||
webhookList = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function addWebhook(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newWebhook.name || !newWebhook.url) return;
|
||||
saving = true;
|
||||
try {
|
||||
const created = await projectsApi.createWebhook(projectId, {
|
||||
name: newWebhook.name,
|
||||
url: newWebhook.url,
|
||||
events: ["*"],
|
||||
});
|
||||
webhookList = [...webhookList, created];
|
||||
isModalOpen = false;
|
||||
newWebhook = { name: "", type: "discord", url: "", secret: "" };
|
||||
} catch {
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(id: string) {
|
||||
try {
|
||||
const updated = await webhooksApi.toggle(id);
|
||||
webhookList = webhookList.map((w) => (w.id === id ? updated : w));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function deleteWebhook(id: string) {
|
||||
try {
|
||||
await webhooksApi.delete(id);
|
||||
webhookList = webhookList.filter((w) => w.id !== id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getWebhookType(url: string): string {
|
||||
if (url.includes("discord.com")) return "discord";
|
||||
if (url.includes("github.com")) return "github";
|
||||
if (url.includes("gitea") || url.includes("git.")) return "gitea";
|
||||
if (url.includes("slack.com")) return "slack";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function getIcon(type: string) {
|
||||
switch (type) {
|
||||
case "discord":
|
||||
return "simple-icons:discord";
|
||||
case "github":
|
||||
return "simple-icons:github";
|
||||
case "gitea":
|
||||
return "simple-icons:gitea";
|
||||
case "slack":
|
||||
return "simple-icons:slack";
|
||||
default:
|
||||
return "lucide:webhook";
|
||||
}
|
||||
}
|
||||
|
||||
function getColor(type: string) {
|
||||
switch (type) {
|
||||
case "discord":
|
||||
return "text-[#5865F2]";
|
||||
case "github":
|
||||
return "text-white";
|
||||
case "gitea":
|
||||
return "text-[#609926]";
|
||||
case "slack":
|
||||
return "text-[#E01E5A]";
|
||||
default:
|
||||
return "text-neutral-400";
|
||||
}
|
||||
}
|
||||
|
||||
function formatLastTriggered(iso: string): string {
|
||||
if (!iso || iso === "0001-01-01T00:00:00Z") return "Never";
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "Just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Webhooks & Integrations — FPMB</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Configure webhooks and integrations with Discord, GitHub, Gitea, Slack, and custom endpoints for this project."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-10">
|
||||
<div class="flex items-center space-x-4 mb-2">
|
||||
<a
|
||||
href="/projects/{projectId}/settings"
|
||||
class="text-neutral-400 hover:text-white transition-colors p-2 rounded-md hover:bg-neutral-800 border border-transparent"
|
||||
>
|
||||
<Icon icon="lucide:arrow-left" class="w-5 h-5" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white tracking-tight">
|
||||
Webhooks & Integrations
|
||||
</h1>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
Connect your project with external tools and services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-neutral-700 mb-8">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<a
|
||||
href="/projects/{projectId}/settings"
|
||||
class="border-transparent text-neutral-400 hover:text-white hover:border-neutral-500 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
General Settings
|
||||
</a>
|
||||
<a
|
||||
href="/projects/{projectId}/webhooks"
|
||||
class="border-blue-500 text-blue-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
|
||||
>
|
||||
Webhooks & Integrations
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="bg-neutral-800 rounded-lg shadow-sm border border-neutral-700 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="p-6 border-b border-neutral-700 flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">
|
||||
Configured Webhooks
|
||||
</h2>
|
||||
<p class="text-sm text-neutral-400">
|
||||
Trigger actions in other apps when events occur in FPMB.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (isModalOpen = true)}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md shadow-sm border border-transparent transition-colors text-sm flex items-center"
|
||||
>
|
||||
<Icon icon="lucide:plus" class="w-4 h-4 mr-2" />
|
||||
Add Webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="p-12 text-center text-neutral-400">Loading webhooks...</div>
|
||||
{:else if webhookList.length === 0}
|
||||
<div class="p-12 text-center flex flex-col items-center justify-center">
|
||||
<Icon icon="lucide:webhook" class="w-12 h-12 text-neutral-600 mb-4" />
|
||||
<h3 class="text-lg font-medium text-white mb-1">No Webhooks Yet</h3>
|
||||
<p class="text-neutral-400 text-sm">
|
||||
Add a webhook to start receiving automated updates in Discord, GitHub,
|
||||
and more.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-neutral-850 border-b border-neutral-700">
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
|
||||
>Integration</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider hidden md:table-cell"
|
||||
>Target URL</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-4 text-xs font-semibold text-neutral-400 uppercase tracking-wider text-right"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-700">
|
||||
{#each webhookList as webhook (webhook.id)}
|
||||
{@const wtype = getWebhookType(webhook.url)}
|
||||
<tr class="hover:bg-neutral-750 transition-colors group">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center w-8 h-8 rounded bg-neutral-900 border border-neutral-700"
|
||||
>
|
||||
<Icon
|
||||
icon={getIcon(wtype)}
|
||||
class="w-5 h-5 {getColor(wtype)}"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div
|
||||
class="text-sm font-medium text-white group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{webhook.name}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500 mt-1 capitalize">
|
||||
{wtype} • Last: {formatLastTriggered(
|
||||
webhook.last_triggered,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400 hidden md:table-cell max-w-[200px] truncate"
|
||||
>
|
||||
{webhook.url}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onclick={() => toggleStatus(webhook.id)}
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border {webhook.active
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: 'bg-neutral-700 text-neutral-400 border-neutral-600'}"
|
||||
>
|
||||
{webhook.active ? "Active" : "Inactive"}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
|
||||
>
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onclick={() => deleteWebhook(webhook.id)}
|
||||
class="text-neutral-400 hover:text-red-400 p-2 rounded hover:bg-neutral-700 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if isModalOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-neutral-900/80 backdrop-blur-sm"
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0" onclick={() => (isModalOpen = false)}></div>
|
||||
|
||||
<div
|
||||
class="relative bg-neutral-800 rounded-lg shadow-xl border border-neutral-700 w-full max-w-lg"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 border-b border-neutral-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-white">Add Webhook</h2>
|
||||
<button
|
||||
onclick={() => (isModalOpen = false)}
|
||||
class="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-700"
|
||||
>
|
||||
<Icon icon="lucide:x" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={addWebhook} class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Service Type</label
|
||||
>
|
||||
<select
|
||||
bind:value={newWebhook.type}
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="custom">Custom Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newWebhook.name}
|
||||
required
|
||||
placeholder="e.g. My Team Discord"
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Payload URL</label
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={newWebhook.url}
|
||||
required
|
||||
placeholder="https://..."
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-1"
|
||||
>Secret Token (Optional)</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newWebhook.secret}
|
||||
placeholder="Used to sign webhook payloads"
|
||||
class="w-full px-3 py-2 border border-neutral-600 rounded-md bg-neutral-700 text-white placeholder-neutral-500 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isModalOpen = false)}
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 hover:text-white hover:bg-neutral-700 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Webhook"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user