Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flnk/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
},
"build": {
"hint": "Add, reorder, and toggle the blocks shown on your page.",
"pinned": "Pinned",
"addBlock": "Add block",
"empty": "No blocks yet. Add your first block to get started.",
"moveUp": "Move up",
Expand Down
1 change: 1 addition & 0 deletions apps/flnk/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
},
"build": {
"hint": "添加、拖拽排序并启用页面上的区块。",
"pinned": "固定",
"addBlock": "添加区块",
"empty": "还没有区块。添加第一个区块开始构建。",
"moveUp": "上移",
Expand Down
11 changes: 6 additions & 5 deletions apps/flnk/src/components/dashboard/launchpads/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import type { LinkRow } from '@/lib/api'

export type BlockType = LaunchpadBlock['type']

// The header is a pinned singleton edited from the fixed card at the top of
// the build tab — it is not addable as a content block.
export type AddableBlockType = Exclude<BlockType, 'header'>

// Add-menu order — mirrors the MVP block set in the spec.
export const BLOCK_TYPES: BlockType[] = [
'header',
export const BLOCK_TYPES: AddableBlockType[] = [
'socials',
'button',
'shortlink',
Expand All @@ -16,11 +19,9 @@ export const BLOCK_TYPES: BlockType[] = [
]

// Fresh block of `type` with sensible empty defaults and a unique id.
export function newBlock(type: BlockType): LaunchpadBlock {
export function newBlock(type: AddableBlockType): LaunchpadBlock {
const id = crypto.randomUUID()
switch (type) {
case 'header':
return { id, type, enabled: true }
case 'socials':
return { id, type, enabled: true, items: [] }
case 'button':
Expand Down
135 changes: 81 additions & 54 deletions apps/flnk/src/components/dashboard/launchpads/build-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { Badge } from '@cdlab996/ui/components/badge'
import { Button } from '@cdlab996/ui/components/button'
import { Card } from '@cdlab996/ui/components/card'
import {
Expand Down Expand Up @@ -28,9 +29,12 @@ import {
} from '@/components/dashboard/launchpads/link-picker'
import type { LaunchpadBlock, LaunchpadConfig } from '@/database/schema'
import type { LinkRow } from '@/lib/api'
import type { BlockType } from './blocks'
import type { AddableBlockType } from './blocks'
import { BLOCK_TYPES, newBlock } from './blocks'

// The header is a pinned singleton (edited via HeaderCard), never a list item.
type ContentBlock = Exclude<LaunchpadBlock, { type: 'header' }>

interface BuildTabProps {
config: LaunchpadConfig
links: LinkRow[]
Expand All @@ -39,16 +43,20 @@ interface BuildTabProps {

export function BuildTab({ config, links, onChange }: BuildTabProps) {
const t = useTranslations('launchpads')
const blocks = config.blocks
// Legacy 'header' blocks are ignored on read; any block edit writes back the
// filtered list, dropping them from config on the next save.
const blocks = config.blocks.filter(
(b): b is ContentBlock => b.type !== 'header',
)
const [dragIndex, setDragIndex] = useState<number | null>(null)

function setBlocks(next: LaunchpadBlock[]) {
onChange({ ...config, blocks: next })
}
function addBlock(type: BlockType) {
function addBlock(type: AddableBlockType) {
setBlocks([...blocks, newBlock(type)])
}
function updateBlock(id: string, block: LaunchpadBlock) {
function updateBlock(id: string, block: ContentBlock) {
setBlocks(blocks.map((b) => (b.id === id ? block : b)))
}
function removeBlock(id: string) {
Expand All @@ -64,6 +72,11 @@ export function BuildTab({ config, links, onChange }: BuildTabProps) {

return (
<div className="space-y-4">
<HeaderCard
profile={config.profile}
onChange={(profile) => onChange({ ...config, profile })}
/>

<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{t('build.hint')}</p>
<DropdownMenu>
Expand Down Expand Up @@ -148,12 +161,8 @@ export function BuildTab({ config, links, onChange }: BuildTabProps) {
<div className="p-3">
<BlockFields
block={block}
config={config}
links={links}
onChange={(b) => updateBlock(block.id, b)}
onProfileChange={(profile) =>
onChange({ ...config, profile })
}
/>
</div>
</Card>
Expand All @@ -164,63 +173,81 @@ export function BuildTab({ config, links, onChange }: BuildTabProps) {
)
}

function BlockFields({
block,
config,
links,
// Fixed profile card pinned above the blocks list — always exactly one, not
// draggable, removable, or toggleable. The public page shows the header iff
// any profile field has content, so no enabled switch here.
function HeaderCard({
profile,
onChange,
onProfileChange,
}: {
block: LaunchpadBlock
config: LaunchpadConfig
links: LinkRow[]
onChange: (block: LaunchpadBlock) => void
onProfileChange: (profile: LaunchpadConfig['profile']) => void
profile: LaunchpadConfig['profile']
onChange: (profile: LaunchpadConfig['profile']) => void
}) {
const t = useTranslations('launchpads')
const linkById = new Map(links.map((l) => [l.id, l]))

switch (block.type) {
case 'header': {
const profile = config.profile
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{t('block.header.hint')}
</p>
<Field label={t('block.header.name')}>
<Input
value={profile.name ?? ''}
maxLength={128}
onChange={(e) =>
onProfileChange({ ...profile, name: e.target.value })
}
/>
</Field>
<Field label={t('block.header.bio')}>
<Textarea
value={profile.bio ?? ''}
maxLength={512}
rows={2}
onChange={(e) =>
onProfileChange({ ...profile, bio: e.target.value })
}
/>
</Field>
<Field label={t('block.header.avatar')}>
return (
<Card className="gap-0 overflow-hidden p-0">
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2">
<span className="flex-1 text-sm font-medium">
{t('block.type.header')}
</span>
<Badge variant="secondary">{t('build.pinned')}</Badge>
</div>
<div className="space-y-3 p-3">
<p className="text-xs text-muted-foreground">
{t('block.header.hint')}
</p>
<Field label={t('block.header.name')}>
<Input
value={profile.name ?? ''}
maxLength={128}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
</Field>
<Field label={t('block.header.bio')}>
<Textarea
value={profile.bio ?? ''}
maxLength={512}
rows={2}
onChange={(e) => onChange({ ...profile, bio: e.target.value })}
/>
</Field>
<Field label={t('block.header.avatar')}>
<div className="flex items-center gap-2">
{profile.avatar && (
// biome-ignore lint/performance/noImgElement: tiny inline preview of a user-supplied remote URL
<img
src={profile.avatar}
alt=""
className="size-8 shrink-0 rounded-full object-cover"
/>
)}
<Input
value={profile.avatar ?? ''}
maxLength={2048}
placeholder="https://…"
onChange={(e) =>
onProfileChange({ ...profile, avatar: e.target.value })
}
onChange={(e) => onChange({ ...profile, avatar: e.target.value })}
/>
</Field>
</div>
)
}
</div>
</Field>
</div>
</Card>
)
}

function BlockFields({
block,
links,
onChange,
}: {
block: ContentBlock
links: LinkRow[]
onChange: (block: ContentBlock) => void
}) {
const t = useTranslations('launchpads')
const linkById = new Map(links.map((l) => [l.id, l]))

switch (block.type) {
case 'socials':
return (
<div className="space-y-2">
Expand Down
Loading