mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 09:49:40 +02:00
feat: add org landing page builder service
New service that generates static Astro landing pages for organizations
and deploys them to Cloudflare Pages at {slug}.mana.how.
Components:
- Landing Builder Service (NestJS, port 3030) with Astro template
- Admin UI in Manacore web dashboard at /organizations/[id]/landing
- TeamSection + ContactSection for shared-landing-ui
- Two org themes (classic dark, warm light)
- LandingPageConfig types in shared-types
- Docker + CI/CD integration for Mac Mini deployment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
da6dd4ecb8
commit
df0b849408
39 changed files with 2171 additions and 4 deletions
63
apps/manacore/apps/web/src/lib/api/services/landing.ts
Normal file
63
apps/manacore/apps/web/src/lib/api/services/landing.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Landing Page API Service
|
||||
*
|
||||
* Handles saving landing config to org metadata and triggering builds.
|
||||
*/
|
||||
|
||||
import { fetchWithRetry, type ApiResult } from '../base-client';
|
||||
import { getManaAuthUrl } from '../config';
|
||||
import type { LandingPageConfig } from '@manacore/shared-types';
|
||||
|
||||
const BUILDER_URL = 'http://localhost:3030';
|
||||
|
||||
interface BuildResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch organization details including metadata
|
||||
*/
|
||||
export async function getOrganization(orgId: string): Promise<ApiResult<any>> {
|
||||
const authUrl = getManaAuthUrl();
|
||||
return fetchWithRetry(`${authUrl}/api/v1/auth/organizations/${orgId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save landing page config to organization metadata
|
||||
*/
|
||||
export async function saveLandingConfig(
|
||||
orgId: string,
|
||||
config: LandingPageConfig,
|
||||
existingMetadata: Record<string, unknown> = {}
|
||||
): Promise<ApiResult<any>> {
|
||||
const authUrl = getManaAuthUrl();
|
||||
return fetchWithRetry(`${authUrl}/api/v1/auth/organizations/${orgId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
...existingMetadata,
|
||||
landingPage: config,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a build of the landing page
|
||||
*/
|
||||
export async function publishLanding(
|
||||
orgId: string,
|
||||
slug: string,
|
||||
config: LandingPageConfig
|
||||
): Promise<ApiResult<BuildResult>> {
|
||||
return fetchWithRetry(`${BUILDER_URL}/api/v1/build`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
organizationId: orgId,
|
||||
slug,
|
||||
config,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
<script lang="ts">
|
||||
import { Button, Card } from '@manacore/shared-ui';
|
||||
import SectionEditor from './SectionEditor.svelte';
|
||||
import RepeatableField from './RepeatableField.svelte';
|
||||
import { saveLandingConfig, publishLanding } from '$lib/api/services/landing';
|
||||
import type {
|
||||
LandingPageConfig,
|
||||
LandingAboutFeature,
|
||||
LandingTeamMember,
|
||||
LandingFooterLink,
|
||||
LandingTheme,
|
||||
} from '@manacore/shared-types';
|
||||
|
||||
let {
|
||||
orgId,
|
||||
orgSlug,
|
||||
initialConfig,
|
||||
existingMetadata = {},
|
||||
}: {
|
||||
orgId: string;
|
||||
orgSlug: string;
|
||||
initialConfig?: LandingPageConfig;
|
||||
existingMetadata?: Record<string, unknown>;
|
||||
} = $props();
|
||||
|
||||
// Default config
|
||||
const defaultConfig: LandingPageConfig = {
|
||||
enabled: true,
|
||||
theme: 'warm',
|
||||
sections: {
|
||||
hero: { title: '', subtitle: '' },
|
||||
about: { title: 'About', features: [] },
|
||||
team: { title: 'Team', members: [] },
|
||||
contact: { title: 'Contact' },
|
||||
footer: {},
|
||||
},
|
||||
};
|
||||
|
||||
let config: LandingPageConfig = $state(
|
||||
initialConfig ? structuredClone(initialConfig) : structuredClone(defaultConfig)
|
||||
);
|
||||
|
||||
let saving = $state(false);
|
||||
let publishing = $state(false);
|
||||
let saveMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
|
||||
const result = await saveLandingConfig(orgId, config, existingMetadata);
|
||||
if (result.error) {
|
||||
saveMessage = { type: 'error', text: result.error };
|
||||
} else {
|
||||
saveMessage = { type: 'success', text: 'Saved' };
|
||||
}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!orgSlug) {
|
||||
saveMessage = { type: 'error', text: 'Organization needs a slug to publish' };
|
||||
return;
|
||||
}
|
||||
|
||||
publishing = true;
|
||||
saveMessage = null;
|
||||
|
||||
// Save first
|
||||
const saveResult = await saveLandingConfig(orgId, config, existingMetadata);
|
||||
if (saveResult.error) {
|
||||
saveMessage = { type: 'error', text: saveResult.error };
|
||||
publishing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Then build
|
||||
const buildResult = await publishLanding(orgId, orgSlug, config);
|
||||
if (buildResult.error) {
|
||||
saveMessage = { type: 'error', text: `Build failed: ${buildResult.error}` };
|
||||
} else if (buildResult.data) {
|
||||
config.publishedUrl = buildResult.data.url;
|
||||
config.lastBuiltAt = new Date().toISOString();
|
||||
config.lastBuildStatus = 'success';
|
||||
// Save updated status
|
||||
await saveLandingConfig(orgId, config, existingMetadata);
|
||||
saveMessage = {
|
||||
type: 'success',
|
||||
text: `Published at ${buildResult.data.url} (${Math.round((buildResult.data.duration || 0) / 1000)}s)`,
|
||||
};
|
||||
}
|
||||
publishing = false;
|
||||
}
|
||||
|
||||
// Helper to add/remove items in arrays
|
||||
function addFeature() {
|
||||
config.sections.about.features = [
|
||||
...config.sections.about.features,
|
||||
{ icon: '', title: '', description: '' },
|
||||
];
|
||||
}
|
||||
|
||||
function removeFeature(index: number) {
|
||||
config.sections.about.features = config.sections.about.features.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addMember() {
|
||||
config.sections.team.members = [...config.sections.team.members, { name: '', role: '' }];
|
||||
}
|
||||
|
||||
function removeMember(index: number) {
|
||||
config.sections.team.members = config.sections.team.members.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addFooterLink() {
|
||||
config.sections.footer.links = [
|
||||
...(config.sections.footer.links || []),
|
||||
{ label: '', href: '' },
|
||||
];
|
||||
}
|
||||
|
||||
function removeFooterLink(index: number) {
|
||||
config.sections.footer.links = (config.sections.footer.links || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Global Settings -->
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Settings</h3>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="theme"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
id="theme"
|
||||
bind:value={config.theme}
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="warm">Warm (Light)</option>
|
||||
<option value="classic">Classic (Dark)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="primary-color"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Primary Color (optional)
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="primary-color"
|
||||
type="color"
|
||||
value={config.customColors?.primary ||
|
||||
(config.theme === 'warm' ? '#d97706' : '#64748b')}
|
||||
oninput={(e) => {
|
||||
if (!config.customColors) config.customColors = {};
|
||||
config.customColors.primary = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
class="h-10 w-14 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={config.customColors?.primary || ''}
|
||||
placeholder="e.g. #3b82f6"
|
||||
oninput={(e) => {
|
||||
if (!config.customColors) config.customColors = {};
|
||||
config.customColors.primary = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
class="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<SectionEditor title="Hero" expanded={true}>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.hero.title}
|
||||
placeholder="Your organization name"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Subtitle</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={config.sections.hero.subtitle}
|
||||
placeholder="A short description of your organization"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Variant</label
|
||||
>
|
||||
<select
|
||||
bind:value={config.sections.hero.variant}
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="centered">Centered</option>
|
||||
<option value="default">Split (Text + Image)</option>
|
||||
<option value="fullwidth">Full Width</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>CTA Button Text</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.sections.hero.primaryCta?.text || ''}
|
||||
oninput={(e) => {
|
||||
if (!config.sections.hero.primaryCta)
|
||||
config.sections.hero.primaryCta = { text: '', href: '#contact' };
|
||||
config.sections.hero.primaryCta.text = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
placeholder="e.g. Contact us"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>CTA Button Link</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.sections.hero.primaryCta?.href || ''}
|
||||
oninput={(e) => {
|
||||
if (!config.sections.hero.primaryCta)
|
||||
config.sections.hero.primaryCta = { text: '', href: '' };
|
||||
config.sections.hero.primaryCta.href = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
placeholder="e.g. #contact or https://..."
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
<!-- About Section -->
|
||||
<SectionEditor title="About / Features">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Section Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.about.title}
|
||||
placeholder="What we offer"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Subtitle</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.about.subtitle}
|
||||
placeholder="Optional subtitle"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Features</label>
|
||||
<RepeatableField
|
||||
items={config.sections.about.features}
|
||||
onAdd={addFeature}
|
||||
onRemove={removeFeature}
|
||||
addLabel="Add Feature"
|
||||
>
|
||||
{#snippet renderItem(feature: LandingAboutFeature, index: number)}
|
||||
<div class="grid gap-2 pr-6">
|
||||
<div class="grid gap-2 md:grid-cols-[60px_1fr]">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.about.features[index].icon}
|
||||
placeholder="Icon"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-center"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.about.features[index].title}
|
||||
placeholder="Feature title"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={config.sections.about.features[index].description}
|
||||
placeholder="Description"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
{/snippet}
|
||||
</RepeatableField>
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
<!-- Team Section -->
|
||||
<SectionEditor title="Team">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Section Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.team.title}
|
||||
placeholder="Our Team"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Members</label>
|
||||
<RepeatableField
|
||||
items={config.sections.team.members}
|
||||
onAdd={addMember}
|
||||
onRemove={removeMember}
|
||||
addLabel="Add Member"
|
||||
>
|
||||
{#snippet renderItem(member: LandingTeamMember, index: number)}
|
||||
<div class="grid gap-2 pr-6">
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.team.members[index].name}
|
||||
placeholder="Name"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.team.members[index].role}
|
||||
placeholder="Role"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.team.members[index].image}
|
||||
placeholder="Image URL (optional)"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</RepeatableField>
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<SectionEditor title="Contact">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Section Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.contact.title}
|
||||
placeholder="Contact"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>E-Mail</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={config.sections.contact.email}
|
||||
placeholder="info@example.com"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Phone</label
|
||||
>
|
||||
<input
|
||||
type="tel"
|
||||
bind:value={config.sections.contact.phone}
|
||||
placeholder="+49 123 456789"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Address</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={config.sections.contact.address}
|
||||
placeholder="Street, City, ZIP"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<SectionEditor title="Footer">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>Copyright Text</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.footer.copyright}
|
||||
placeholder="e.g. 2024 My Organization. All rights reserved."
|
||||
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Links</label>
|
||||
<RepeatableField
|
||||
items={config.sections.footer.links || []}
|
||||
onAdd={addFooterLink}
|
||||
onRemove={removeFooterLink}
|
||||
addLabel="Add Link"
|
||||
>
|
||||
{#snippet renderItem(link: LandingFooterLink, index: number)}
|
||||
<div class="grid gap-2 md:grid-cols-2 pr-6">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.footer.links || [])[index].label}
|
||||
placeholder="Label"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={config.sections.footer.links || [])[index].href}
|
||||
placeholder="URL"
|
||||
class="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</RepeatableField>
|
||||
</div>
|
||||
</SectionEditor>
|
||||
|
||||
<!-- Status Message -->
|
||||
{#if saveMessage}
|
||||
<div
|
||||
class="rounded-lg px-4 py-3 text-sm {saveMessage.type === 'success'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}"
|
||||
>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="secondary" onclick={handleSave} disabled={saving || publishing}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handlePublish} disabled={saving || publishing}>
|
||||
{#if publishing}
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
></div>
|
||||
Building...
|
||||
</div>
|
||||
{:else}
|
||||
Publish
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if config.publishedUrl}
|
||||
<a
|
||||
href={config.publishedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-auto text-sm text-primary-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{config.publishedUrl}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts" generics="T">
|
||||
import { Button } from '@manacore/shared-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
items = [],
|
||||
onAdd,
|
||||
onRemove,
|
||||
addLabel = 'Add',
|
||||
renderItem,
|
||||
}: {
|
||||
items: T[];
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
addLabel?: string;
|
||||
renderItem: Snippet<[T, number]>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each items as item, index}
|
||||
<div class="relative border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onRemove(index)}
|
||||
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{@render renderItem(item, index)}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAdd}
|
||||
class="w-full flex items-center justify-center gap-2 py-2 px-4 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
expanded = false,
|
||||
children,
|
||||
}: { title: string; expanded?: boolean; children: Snippet } = $props();
|
||||
|
||||
let isExpanded = $state(expanded);
|
||||
</script>
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isExpanded = !isExpanded)}
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white">{title}</span>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-500 transition-transform {isExpanded ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="px-4 py-4 space-y-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Card, Button, PageHeader } from '@manacore/shared-ui';
|
||||
import { getOrganization } from '$lib/api/services/landing';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let org: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: 'home' },
|
||||
{ id: 'landing', label: 'Landing Page', icon: 'globe' },
|
||||
{ id: 'members', label: 'Members', icon: 'users' },
|
||||
];
|
||||
|
||||
let activeTab = $state('overview');
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
home: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />',
|
||||
globe:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />',
|
||||
users:
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const result = await getOrganization(data.orgId);
|
||||
if (result.data) {
|
||||
org = result.data;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card>
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-red-500">{error}</p>
|
||||
<a href="/organizations" class="mt-4 inline-block text-sm text-primary-600 hover:underline">
|
||||
Back to organizations
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if org}
|
||||
<div class="space-y-6">
|
||||
<PageHeader title={org.name} description={org.slug ? `${org.slug}.mana.how` : 'Organization'}>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/organizations"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back
|
||||
</a>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav class="flex gap-1 border-b pb-px">
|
||||
{#each tabs as tab}
|
||||
{@const active = activeTab === tab.id}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (tab.id === 'landing') {
|
||||
window.location.href = `/organizations/${data.orgId}/landing`;
|
||||
} else {
|
||||
activeTab = tab.id;
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-t-lg transition-colors
|
||||
{active
|
||||
? 'text-primary border-b-2 border-primary -mb-px bg-primary/5'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{@html icons[tab.id]}
|
||||
</svg>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Tab Content -->
|
||||
{#if activeTab === 'overview'}
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Details</h3>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{org.name}</dd>
|
||||
</div>
|
||||
{#if org.slug}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Slug</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">{org.slug}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
{new Date(org.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Landing Page</h3>
|
||||
{#if org.metadata?.landingPage?.enabled}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
Active
|
||||
</p>
|
||||
{#if org.metadata.landingPage.publishedUrl}
|
||||
<a
|
||||
href={org.metadata.landingPage.publishedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
{org.metadata.landingPage.publishedUrl}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Not configured yet</p>
|
||||
{/if}
|
||||
<div class="mt-4">
|
||||
<a href="/organizations/{data.orgId}/landing">
|
||||
<Button variant="primary" size="sm">Configure Landing Page</Button>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{:else if activeTab === 'members'}
|
||||
<Card>
|
||||
<div class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Member management coming soon.
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return {
|
||||
orgId: params.id,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import LandingEditor from '$lib/components/landing/LandingEditor.svelte';
|
||||
import { getOrganization } from '$lib/api/services/landing';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let org: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const result = await getOrganization(data.orgId);
|
||||
if (result.data) {
|
||||
org = result.data;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-red-500">{error}</p>
|
||||
<a
|
||||
href="/organizations/{data.orgId}"
|
||||
class="mt-4 inline-block text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Back to organization
|
||||
</a>
|
||||
</div>
|
||||
{:else if org}
|
||||
<div class="space-y-6">
|
||||
<PageHeader title="Landing Page" description="Configure the public landing page for {org.name}">
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/organizations/{data.orgId}"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back to {org.name}
|
||||
</a>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<LandingEditor
|
||||
orgId={data.orgId}
|
||||
orgSlug={org.slug || ''}
|
||||
initialConfig={org.metadata?.landingPage}
|
||||
existingMetadata={org.metadata || {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
return {
|
||||
orgId: params.id,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue