Skip to content

Commit db3b150

Browse files
aster-voidclaude
andcommitted
treewide: implement pageContent and transferLead features
- Add pageContent (自己紹介ページ) field to MemberForm with markdown editor - Display pageContent on public member detail page with Markdown rendering - Add transferLead remote function for project lead handover - Add Transfer Lead UI button and modal in project edit page - Update data-models.md to mark both features as implemented 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 9b9ee19 commit db3b150

File tree

8 files changed

+195
-7
lines changed

8 files changed

+195
-7
lines changed

docs/knowledges/data-models.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ projectMembers
9191
| メンバーを検索する | ||
9292
| メンバー一覧を見る |||
9393
| メンバー詳細を見る |||
94-
| 自己紹介ページを書く | | |
94+
| 自己紹介ページを書く | | |
9595

9696
### Articles
9797

@@ -122,7 +122,7 @@ projectMembers
122122
| プロジェクトを検索する | ||
123123
| プロジェクト一覧を見る |||
124124
| プロジェクト詳細を見る |||
125-
| リードを引き継ぐ | | |
125+
| リードを引き継ぐ | | |
126126

127127
### Search
128128

src/lib/components/MemberForm.svelte

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44
import { onSaveShortcut } from "$lib/utils/keyboard";
55
import { validateSlug, generateSlug } from "$lib/shared/logic/slugs";
66
import ImageUpload from "./image-upload.svelte";
7+
import Markdown from "./Markdown.svelte";
78
import { Loader2, ArrowLeft, Settings, X } from "lucide-svelte";
89
910
type MemberData = {
1011
slug: string;
1112
name: string;
1213
bio: string;
1314
imageUrl: string;
15+
pageContent: string;
1416
};
1517
1618
let {
17-
initialData = { slug: "", name: "", bio: "", imageUrl: "" },
19+
initialData = { slug: "", name: "", bio: "", imageUrl: "", pageContent: "" },
1820
onSubmit,
1921
onDelete = null,
2022
submitLabel = "Save",
@@ -30,6 +32,7 @@
3032
let formData = $state(snapshot(() => initialData));
3133
let errors = $state<Record<string, string>>({});
3234
let showSettings = $state(false);
35+
let showPageContentPreview = $state(false);
3336
3437
function handleNameChange() {
3538
if (!formData.slug || formData.slug === generateSlug(initialData.name)) {
@@ -165,6 +168,54 @@
165168
placeholder="A short bio about this member..."
166169
></textarea>
167170
</div>
171+
172+
<!-- Page Content -->
173+
<div class="mt-8 border-t border-zinc-200 pt-8">
174+
<div class="mb-4 flex items-center justify-between">
175+
<label for="pageContent" class="block text-sm font-medium text-zinc-700">
176+
自己紹介ページ
177+
</label>
178+
<div class="flex items-center gap-2">
179+
<button
180+
type="button"
181+
onclick={() => (showPageContentPreview = false)}
182+
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {!showPageContentPreview
183+
? 'bg-zinc-100 text-zinc-900'
184+
: 'text-zinc-500 hover:text-zinc-700'}"
185+
>
186+
Write
187+
</button>
188+
<button
189+
type="button"
190+
onclick={() => (showPageContentPreview = true)}
191+
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {showPageContentPreview
192+
? 'bg-zinc-100 text-zinc-900'
193+
: 'text-zinc-500 hover:text-zinc-700'}"
194+
>
195+
Preview
196+
</button>
197+
</div>
198+
</div>
199+
<p class="mb-3 text-xs text-zinc-400">
200+
Markdown supported - This will be displayed on your public profile page
201+
</p>
202+
{#if showPageContentPreview}
203+
<div class="min-h-[40vh] rounded-lg border border-zinc-200 bg-white px-4 py-3">
204+
{#if formData.pageContent.trim()}
205+
<Markdown content={formData.pageContent} />
206+
{:else}
207+
<p class="text-zinc-400">Nothing to preview</p>
208+
{/if}
209+
</div>
210+
{:else}
211+
<textarea
212+
id="pageContent"
213+
bind:value={formData.pageContent}
214+
class="min-h-[40vh] w-full resize-none rounded-lg border border-zinc-200 bg-white px-4 py-3 font-[JetBrains_Mono,monospace] text-sm leading-relaxed text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-400 focus:ring-0 focus:outline-none"
215+
placeholder="Write detailed information about yourself using Markdown..."
216+
></textarea>
217+
{/if}
218+
</div>
168219
</div>
169220
</main>
170221

src/lib/data/private/members.remote.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const saveMember = command(
2525
name: v.string(),
2626
bio: v.nullable(v.string()),
2727
imageUrl: v.nullable(v.string()),
28+
pageContent: v.nullable(v.string()),
2829
}),
2930
async (data) => {
3031
await requireUtCodeMember();
@@ -40,6 +41,7 @@ export const editMember = command(
4041
name: v.optional(v.string()),
4142
bio: v.optional(v.nullable(v.string())),
4243
imageUrl: v.optional(v.nullable(v.string())),
44+
pageContent: v.optional(v.nullable(v.string())),
4345
}),
4446
}),
4547
async ({ id, data }) => {

src/lib/data/private/projects.remote.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
deleteProject,
1010
addProjectMember,
1111
removeProjectMember,
12+
transferLead as serverTransferLead,
1213
} from "$lib/server/database/projects.server";
1314
import { listMembers } from "$lib/server/database/members.server";
1415
import { type ProjectCategory, type ProjectRole } from "$lib/shared/models/schema";
@@ -109,3 +110,27 @@ export const removeMember = command(
109110
await removeProjectMember(projectId, memberId);
110111
},
111112
);
113+
114+
export const transferLead = command(
115+
v.object({
116+
projectId: v.string(),
117+
newLeadMemberId: v.string(),
118+
}),
119+
async ({ projectId, newLeadMemberId }) => {
120+
await requireUtCodeMember();
121+
const project = await getProjectById(projectId);
122+
if (!project) throw new Error("Project not found");
123+
124+
const currentLead = project.projectMembers.find((pm) => pm.role === "lead");
125+
if (!currentLead) throw new Error("No current lead found");
126+
127+
const newLeadMember = project.projectMembers.find((pm) => pm.memberId === newLeadMemberId);
128+
if (!newLeadMember) throw new Error("New lead is not a member of this project");
129+
130+
if (currentLead.memberId === newLeadMemberId) {
131+
throw new Error("Member is already the lead");
132+
}
133+
134+
await serverTransferLead(projectId, currentLead.memberId, newLeadMemberId);
135+
},
136+
);

src/routes/(admin)/admin/members/edit/[id]/+page.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
const member = $derived(await getMember(id));
1313
let isSubmitting = $state(false);
1414
15-
async function handleSubmit(data: { slug: string; name: string; bio: string; imageUrl: string }) {
15+
async function handleSubmit(data: {
16+
slug: string;
17+
name: string;
18+
bio: string;
19+
imageUrl: string;
20+
pageContent: string;
21+
}) {
1622
if (!member) return;
1723
1824
try {
@@ -33,6 +39,7 @@
3339
name: data.name,
3440
bio: data.bio || null,
3541
imageUrl: data.imageUrl || null,
42+
pageContent: data.pageContent || null,
3643
},
3744
});
3845
toast.show("Saved", "success");
@@ -96,6 +103,7 @@
96103
name: member.name,
97104
bio: member.bio ?? "",
98105
imageUrl: member.imageUrl ?? "",
106+
pageContent: member.pageContent ?? "",
99107
}}
100108
onSubmit={handleSubmit}
101109
onDelete={handleDelete}

src/routes/(admin)/admin/members/new/+page.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77
const toast = useToast();
88
let isSubmitting = $state(false);
99
10-
async function handleSubmit(data: { slug: string; name: string; bio: string; imageUrl: string }) {
10+
async function handleSubmit(data: {
11+
slug: string;
12+
name: string;
13+
bio: string;
14+
imageUrl: string;
15+
pageContent: string;
16+
}) {
1117
try {
1218
const result = await saveMember({
1319
slug: data.slug,
1420
name: data.name,
1521
bio: data.bio || null,
1622
imageUrl: data.imageUrl || null,
23+
pageContent: data.pageContent || null,
1724
});
1825
if (result) {
1926
toast.show("Created", "success");

src/routes/(admin)/admin/projects/edit/[id]/+page.svelte

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
removeProject,
1111
addMember,
1212
removeMember,
13+
transferLead,
1314
} from "$lib/data/private/projects.remote";
1415
import { useToast } from "$lib/components/toast/controls.svelte";
15-
import { Folder, Plus, X } from "lucide-svelte";
16+
import { Folder, Plus, X, ArrowRightLeft } from "lucide-svelte";
1617
import type { ProjectCategory } from "$lib/shared/models/schema";
1718
1819
const toast = useToast();
@@ -23,6 +24,12 @@
2324
let showAddMember = $state(false);
2425
let newMemberId = $state<string | null>(null);
2526
let newMemberRole = $state<"member" | "lead">("member");
27+
let showTransferLead = $state(false);
28+
let transferTargetId = $state<string | null>(null);
29+
30+
const currentLead = $derived(project?.projectMembers.find((pm) => pm.role === "lead"));
31+
const otherMembers = $derived(project?.projectMembers.filter((pm) => pm.role !== "lead") ?? []);
32+
const canTransferLead = $derived((otherMembers?.length ?? 0) > 0);
2633
2734
async function handleSubmit(data: {
2835
slug: string;
@@ -120,6 +127,33 @@
120127
}
121128
}
122129
}
130+
131+
async function handleTransferLead() {
132+
if (!transferTargetId || !currentLead) return;
133+
134+
const targetMember = otherMembers.find((m) => m.memberId === transferTargetId);
135+
if (!targetMember) return;
136+
137+
const confirmed = await confirm({
138+
title: "Transfer lead role?",
139+
description: `Transfer lead role from ${currentLead.member.name} to ${targetMember.member.name}?`,
140+
confirmText: "Transfer",
141+
variant: "warning",
142+
});
143+
144+
if (confirmed) {
145+
try {
146+
await transferLead({ projectId: id, newLeadMemberId: transferTargetId }).updates(
147+
getProject(id),
148+
);
149+
toast.show("Lead transferred", "success");
150+
showTransferLead = false;
151+
transferTargetId = null;
152+
} catch (error) {
153+
toast.show(error instanceof Error ? error.message : "Failed to transfer");
154+
}
155+
}
156+
}
123157
</script>
124158

125159
<svelte:head>
@@ -152,7 +186,7 @@
152186
<!-- Team Members Bar -->
153187
<div class="flex items-center gap-3 border-b border-zinc-200 bg-zinc-50 px-4 py-2">
154188
<span class="text-xs font-medium text-zinc-500">Team:</span>
155-
<div class="flex items-center gap-1">
189+
<div class="flex flex-1 items-center gap-1">
156190
{#each project.projectMembers as pm (pm.memberId)}
157191
<div
158192
class="group flex items-center gap-1.5 rounded-full bg-white py-1 pr-2 pl-1 text-xs shadow-sm ring-1 ring-zinc-200"
@@ -192,6 +226,16 @@
192226
<Plus class="h-4 w-4" />
193227
</button>
194228
</div>
229+
{#if canTransferLead}
230+
<button
231+
type="button"
232+
onclick={() => (showTransferLead = true)}
233+
class="flex items-center gap-1.5 rounded-lg bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50"
234+
>
235+
<ArrowRightLeft class="h-3.5 w-3.5" />
236+
Transfer Lead
237+
</button>
238+
{/if}
195239
</div>
196240

197241
<!-- Main Form -->
@@ -268,5 +312,49 @@
268312
</div>
269313
</div>
270314
{/if}
315+
316+
<!-- Transfer Lead Modal -->
317+
{#if showTransferLead}
318+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
319+
<div class="w-full max-w-sm rounded-xl bg-white p-5 shadow-xl">
320+
<h3 class="font-semibold text-zinc-900">Transfer lead role</h3>
321+
<div class="mt-4 space-y-1.5">
322+
<label for="transferTarget" class="text-sm font-medium text-zinc-700"
323+
>New lead member</label
324+
>
325+
<select
326+
id="transferTarget"
327+
bind:value={transferTargetId}
328+
class="w-full rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
329+
>
330+
<option value={null}>Select</option>
331+
{#each otherMembers as pm (pm.memberId)}
332+
<option value={pm.memberId}>{pm.member.name}</option>
333+
{/each}
334+
</select>
335+
</div>
336+
<div class="mt-5 flex justify-end gap-2">
337+
<button
338+
type="button"
339+
onclick={() => {
340+
showTransferLead = false;
341+
transferTargetId = null;
342+
}}
343+
class="rounded-lg px-3 py-2 text-sm font-medium text-zinc-600 hover:bg-zinc-100"
344+
>
345+
Cancel
346+
</button>
347+
<button
348+
type="button"
349+
onclick={handleTransferLead}
350+
disabled={!transferTargetId}
351+
class="rounded-lg bg-zinc-900 px-3 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
352+
>
353+
Transfer
354+
</button>
355+
</div>
356+
</div>
357+
</div>
358+
{/if}
271359
{/if}
272360
</svelte:boundary>

src/routes/(site)/members/[slug]/+page.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { page } from "$app/state";
33
import { getPublicMember } from "$lib/data/public/index.remote";
4+
import Markdown from "$lib/components/Markdown.svelte";
45
56
const slug = $derived(page.params.slug ?? "");
67
const member = $derived(await getPublicMember(slug));
@@ -54,6 +55,12 @@
5455
</div>
5556
</div>
5657

58+
{#if member.pageContent}
59+
<section class="mt-12 border-t border-zinc-200 pt-8">
60+
<Markdown content={member.pageContent} />
61+
</section>
62+
{/if}
63+
5764
{#if member.projectMembers && member.projectMembers.length > 0}
5865
<section class="mt-12 border-t border-zinc-200 pt-8">
5966
<h2 class="mb-4 text-xl font-semibold">参加プロジェクト</h2>

0 commit comments

Comments
 (0)