Skip to content

Commit 085de44

Browse files
authored
Merge pull request #2126 from AtCoder-NoviSteps/#2020
✨ Enable to use url slug in workbook page (#2020)
2 parents 59d2ece + 954d72d commit 085de44

File tree

24 files changed

+673
-507
lines changed

24 files changed

+673
-507
lines changed

prisma/ERD.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ OTHERS OTHERS
178178
Boolean isOfficial
179179
Boolean isReplenished
180180
WorkBookType workBookType
181+
String urlSlug "❓"
181182
DateTime createdAt
182183
DateTime updatedAt
183184
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*
2+
Warnings:
3+
4+
- A unique constraint covering the columns `[urlSlug]` on the table `workbook` will be added. If there are existing duplicate values, this will fail.
5+
6+
*/
7+
-- AlterTable
8+
ALTER TABLE "workbook" ADD COLUMN "urlSlug" TEXT;
9+
10+
-- CreateIndex
11+
CREATE UNIQUE INDEX "workbook_urlSlug_key" ON "workbook"("urlSlug");
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
2-
# It should be added in your version-control system (i.e. Git)
3-
provider = "postgresql"
2+
# It should be added in your version-control system (e.g., Git)
3+
provider = "postgresql"

prisma/schema.prisma

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ model WorkBook {
181181
isOfficial Boolean @default(false)
182182
isReplenished Boolean @default(false) // カリキュラムの【補充】を識別するために使用
183183
workBookType WorkBookType @default(CREATED_BY_USER)
184+
urlSlug String? @unique // 問題集(カリキュラムと解法別)をURLで識別するためのオプション。a-z、0-9、(-)ハイフンのみ使用可能。例: bfs、dfs、dp、union-find、2-sat。
184185
createdAt DateTime @default(now())
185186
updatedAt DateTime @updatedAt
186187
@@ -231,7 +232,7 @@ enum ContestType {
231232
}
232233

233234
// 11Q(最も簡単)〜6D(最難関)。
234-
// 注: 基準は非公開
235+
// 注: 基準は一般公開中
235236
enum TaskGrade {
236237
PENDING // 未確定
237238
Q11 // 11Qのように表記したいが、数字を最初の文字として利用できないため
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script lang="ts">
2+
import { Heading, Button } from 'svelte-5-ui-lib';
3+
import HeadingOne from '$lib/components/HeadingOne.svelte';
4+
5+
interface Props {
6+
errorStatus: number | undefined;
7+
errorMessage: string | undefined;
8+
returnUrl: string;
9+
returnButtonLabel: string;
10+
}
11+
12+
let { errorStatus, errorMessage, returnUrl, returnButtonLabel }: Props = $props();
13+
</script>
14+
15+
<div
16+
class="container mx-auto md:w-4/5 lg:w-2/3 py-4 md:py-8 px-3 md:px-0 flex flex-col items-center"
17+
>
18+
<HeadingOne title="エラーが発生しました" />
19+
20+
<Heading
21+
tag="h2"
22+
class="text-3xl mb-3 text-gray-900 dark:text-gray-300"
23+
aria-label="error status"
24+
>
25+
{errorStatus ?? ''}
26+
</Heading>
27+
28+
<p class="dark:text-gray-300" aria-label="error messages">{errorMessage ?? ''}</p>
29+
30+
<div class="flex justify-center mt-6">
31+
<Button href={returnUrl} color="primary" class="px-6">
32+
{returnButtonLabel}
33+
</Button>
34+
</div>
35+
</div>

src/lib/components/WorkBook/WorkBookForm.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { Breadcrumb, BreadcrumbItem } from 'svelte-5-ui-lib';
33
44
import HeadingOne from '$lib/components/HeadingOne.svelte';
5-
import WorkBookInputFields from '$lib/components/WorkBooks/WorkBookInputFields.svelte';
5+
import WorkBookInputFields from '$lib/components/WorkBook/WorkBookInputFields.svelte';
66
import WorkBookTasksTable from '$lib/components/WorkBookTasks/WorkBookTasksTable.svelte';
77
import TaskSearchBox from '$lib/components/TaskSearchBox.svelte';
88
import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte';
@@ -88,6 +88,7 @@
8888
bind:isPublished={$form.isPublished}
8989
bind:isOfficial={$form.isOfficial}
9090
bind:isReplenished={$form.isReplenished}
91+
bind:urlSlug={$form.urlSlug}
9192
bind:workBookType={$form.workBookType}
9293
{isAdmin}
9394
message={$message}

src/lib/components/WorkBooks/WorkBookInputFields.svelte renamed to src/lib/components/WorkBook/WorkBookInputFields.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
isPublished: boolean;
1414
isOfficial: boolean;
1515
isReplenished: boolean;
16+
urlSlug?: string | null;
1617
workBookType: WorkBookType;
1718
isAdmin: boolean;
1819
isEditable?: boolean;
@@ -28,6 +29,7 @@
2829
isPublished = $bindable(),
2930
isOfficial = $bindable(),
3031
isReplenished = $bindable(),
32+
urlSlug = $bindable(undefined),
3133
workBookType = $bindable(),
3234
isAdmin,
3335
isEditable = true,
@@ -144,3 +146,15 @@
144146
/>
145147
</div>
146148
</div>
149+
150+
<!-- 管理者のみ: 問題集のカスタムURL (一般ユーザには非表示) -->
151+
<InputFieldWrapper
152+
inputFieldType={isAdmin ? null : 'hidden'}
153+
labelName={isAdmin
154+
? '問題集のカスタムURL(30文字以下、半角英小文字・半角数字・ハイフンのみ。ただし、数字のみは不可)'
155+
: ''}
156+
inputFieldName="urlSlug"
157+
bind:inputValue={urlSlug}
158+
isEditable={isAdmin && isEditable}
159+
message={errors.urlSlug}
160+
/>

src/lib/components/WorkBooks/TitleTableBodyCell.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { TableBodyCell } from 'svelte-5-ui-lib';
33
44
import type { WorkbookList } from '$lib/types/workbook';
5+
6+
import { getUrlSlugFrom } from '$lib/utils/workbooks';
57
import PublicationStatusLabel from '$lib/components/WorkBooks/PublicationStatusLabel.svelte';
68
79
interface Props {
@@ -18,7 +20,7 @@
1820
>
1921
<PublicationStatusLabel isPublished={workbook.isPublished} />
2022
<a
21-
href="/workbooks/{workbook.id}"
23+
href="/workbooks/{getUrlSlugFrom(workbook)}"
2224
class="flex-1 font-medium xs:text-lg text-primary-600 hover:underline dark:text-primary-500 truncate"
2325
aria-labelledby="View details for workbook: {workbook.title}"
2426
>

src/lib/components/WorkBooks/WorkBookBaseTable.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import AcceptedCounter from '$lib/components/SubmissionStatus/AcceptedCounter.svelte';
2323
2424
import { canRead, canEdit, canDelete } from '$lib/utils/authorship';
25+
import { getUrlSlugFrom } from '$lib/utils/workbooks';
2526
2627
interface Props {
2728
workbookType: WorkBookType;
@@ -123,7 +124,7 @@
123124
class="flex justify-center items-center space-x-3 min-w-[96px] max-w-[120px] text-gray-700 dark:text-gray-300"
124125
>
125126
{#if canEdit(userId, workbook.authorId, role, workbook.isPublished)}
126-
<a href="/workbooks/edit/{workbook.id}">編集</a>
127+
<a href="/workbooks/edit/{getUrlSlugFrom(workbook)}">編集</a>
127128
{/if}
128129

129130
{#if canDelete(userId, workbook.authorId)}

src/lib/services/workbooks.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,39 @@ export async function getWorkBook(workBookId: number): Promise<WorkBook | null>
3737
return workBook;
3838
}
3939

40+
/**
41+
* Retrieves a WorkBook from the database by its URL slug.
42+
*
43+
* @param urlSlug - The URL slug identifier for the WorkBook to retrieve (e.g., 'bfs', 'dfs', 'union-find', '2-sat').
44+
* @returns A Promise that resolves to the found WorkBook (with included workBookTasks
45+
* ordered by priority) or null if no WorkBook with the given slug exists
46+
*/
47+
export async function getWorkBookByUrlSlug(urlSlug: string): Promise<WorkBook | null> {
48+
const workBook = await db.workBook.findUnique({
49+
where: {
50+
urlSlug: urlSlug,
51+
},
52+
include: {
53+
workBookTasks: {
54+
orderBy: {
55+
priority: 'asc',
56+
},
57+
},
58+
},
59+
});
60+
61+
return workBook;
62+
}
63+
4064
// See:
4165
// https://www.prisma.io/docs/orm/prisma-schema/data-model/relations#create-a-record-and-nested-records
4266
export async function createWorkBook(workBook: WorkBook): Promise<void> {
67+
const slug = workBook.urlSlug;
68+
69+
if (slug && (await isExistingUrlSlug(slug))) {
70+
throw new Error(`WorkBook slug ${slug} has already existed`);
71+
}
72+
4373
const sanitizedUrl = sanitizeUrl(workBook.editorialUrl);
4474
const newWorkBookTasks: WorkBookTasksBase = await getWorkBookTasks(workBook);
4575

@@ -53,6 +83,7 @@ export async function createWorkBook(workBook: WorkBook): Promise<void> {
5383
isOfficial: workBook.isOfficial,
5484
isReplenished: workBook.isReplenished,
5585
workBookType: workBook.workBookType as WorkBookType,
86+
urlSlug: workBook.urlSlug,
5687
workBookTasks: {
5788
create: newWorkBookTasks,
5889
},
@@ -65,6 +96,10 @@ export async function createWorkBook(workBook: WorkBook): Promise<void> {
6596
console.log(`Created workbook with title: ${newWorkBook.title}`);
6697
}
6798

99+
async function isExistingUrlSlug(slug: string): Promise<boolean> {
100+
return !!(await getWorkBookByUrlSlug(slug));
101+
}
102+
68103
async function isExistingWorkBook(workBookId: number): Promise<boolean> {
69104
const workBook = await getWorkBook(workBookId);
70105

0 commit comments

Comments
 (0)