From 7035adf344b91339d2b64f3e91583081c6d8f3f3 Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 15:34:09 +0900 Subject: [PATCH 1/7] Add an API to publish a slide from the preview Co-Authored-By: Claude Opus 5.5 --- src/lib/view-models/slides.ts | 2 + src/server/api/slides.test.ts | 188 ++++++++++++++++++++++++++++++++++ src/server/api/slides.ts | 37 ++++++- 3 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/server/api/slides.test.ts diff --git a/src/lib/view-models/slides.ts b/src/lib/view-models/slides.ts index 6477753..5e2c103 100644 --- a/src/lib/view-models/slides.ts +++ b/src/lib/view-models/slides.ts @@ -6,6 +6,8 @@ export type SlidesShowViewModel = { slides_show_path: string; slide_path: string; published: boolean; + modified: boolean; + is_older_than_remote: boolean; theme: string | null; }; diff --git a/src/server/api/slides.test.ts b/src/server/api/slides.test.ts new file mode 100644 index 0000000..f362835 --- /dev/null +++ b/src/server/api/slides.test.ts @@ -0,0 +1,188 @@ +import express from "express"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { QiitaSlide } from "../../lib/entities/qiita-slide"; +import { getQiitaApiInstance } from "../../lib/get-qiita-api-instance"; +import { getSlideFileSystemRepo } from "../../lib/get-slide-file-system-repo"; +import type { SlideFileSystemRepo } from "../../lib/slide-file-system-repo"; +import type { QiitaApi, Slide } from "../../qiita-api"; +import { SlidesRouter } from "./slides"; + +jest.mock("../../lib/get-slide-file-system-repo"); +jest.mock("../../lib/get-qiita-api-instance"); + +const mockGetSlideFileSystemRepo = jest.mocked(getSlideFileSystemRepo); +const mockGetQiitaApiInstance = jest.mocked(getQiitaApiInstance); + +describe("SlidesRouter", () => { + let server: Server; + let baseUrl: string; + + const slideFileSystemRepo = { + loadSlideByBasename: jest.fn(), + loadSlideById: jest.fn(), + publishSlide: jest.fn(), + } as unknown as jest.Mocked; + + const qiitaApi = { + previewSlide: jest.fn(), + } as unknown as jest.Mocked; + + const buildSlide = ( + overrides: Partial[0]> = {}, + ) => + new QiitaSlide({ + id: null, + title: "Title", + description: null, + rawBody: "# Title", + updatedAt: null, + name: "slide", + slidesShowPath: "/slides/show?basename=slide", + published: false, + modified: true, + isOlderThanRemote: false, + slidePath: "/data_root_dir/slides/slide.md", + marpFrontmatter: {}, + ignorePublish: false, + ...overrides, + }); + + const buildResponseSlide = (overrides: Partial = {}): Slide => ({ + uuid: "new-slide-uuid", + title: "Title", + markdown: "# Title", + description_markdown: "", + created_at: "2026-09-01T00:00:00+09:00", + updated_at: "2026-09-01T00:00:00+09:00", + url: "https://qiita.com/slides/new-slide-uuid", + ...overrides, + }); + + const postSlidesUpdate = async (id: string, body: object) => + await fetch(`${baseUrl}/api/slides/${id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use("/api/slides", SlidesRouter); + server = createServer(app); + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => resolve()); + server.once("error", reject); + }); + + const address = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetSlideFileSystemRepo.mockResolvedValue(slideFileSystemRepo); + mockGetQiitaApiInstance.mockResolvedValue(qiitaApi); + slideFileSystemRepo.loadSlideByBasename.mockResolvedValue(null); + slideFileSystemRepo.loadSlideById.mockResolvedValue(null); + }); + + describe("GET /api/slides/:id", () => { + it("returns whether the slide is modified or older than the remote", async () => { + slideFileSystemRepo.loadSlideById.mockResolvedValue( + buildSlide({ + id: "existing-slide-uuid", + published: true, + modified: false, + isOlderThanRemote: true, + }), + ); + qiitaApi.previewSlide.mockResolvedValue({ pages: [], css: "" }); + + const response = await fetch(`${baseUrl}/api/slides/existing-slide-uuid`); + + expect(await response.json()).toMatchObject({ + modified: false, + is_older_than_remote: true, + }); + }); + }); + + describe("POST /api/slides/post with a basename", () => { + it("publishes the slide looked up by basename and returns the new uuid", async () => { + const slide = buildSlide(); + slideFileSystemRepo.loadSlideByBasename.mockResolvedValue(slide); + slideFileSystemRepo.publishSlide.mockResolvedValue({ + slide: buildResponseSlide(), + posted: true, + }); + + const response = await postSlidesUpdate("post", { basename: "slide" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + success: true, + uuid: "new-slide-uuid", + }); + expect(slideFileSystemRepo.loadSlideByBasename).toHaveBeenCalledWith( + "slide", + ); + expect(slideFileSystemRepo.publishSlide).toHaveBeenCalledWith( + slide, + qiitaApi, + ); + }); + }); + + describe("POST /api/slides/:id for an already published slide", () => { + it("publishes the slide looked up by uuid and returns the same uuid", async () => { + const slide = buildSlide({ id: "existing-slide-uuid", published: true }); + slideFileSystemRepo.loadSlideById.mockResolvedValue(slide); + slideFileSystemRepo.publishSlide.mockResolvedValue({ + slide: buildResponseSlide({ uuid: "existing-slide-uuid" }), + posted: false, + }); + + const response = await postSlidesUpdate("existing-slide-uuid", {}); + + expect(await response.json()).toEqual({ + success: true, + uuid: "existing-slide-uuid", + }); + expect(slideFileSystemRepo.loadSlideById).toHaveBeenCalledWith( + "existing-slide-uuid", + ); + }); + }); + + describe("when the slide is not found", () => { + it("returns 404 without publishing", async () => { + const response = await postSlidesUpdate("unknown-uuid", {}); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ message: "Not found" }); + expect(slideFileSystemRepo.publishSlide).not.toHaveBeenCalled(); + }); + }); + + describe("when publishing fails", () => { + it("returns success: false", async () => { + slideFileSystemRepo.loadSlideById.mockResolvedValue( + buildSlide({ id: "existing-slide-uuid", published: true }), + ); + slideFileSystemRepo.publishSlide.mockRejectedValue( + new Error("Forbidden"), + ); + + const response = await postSlidesUpdate("existing-slide-uuid", {}); + + expect(await response.json()).toEqual({ success: false }); + }); + }); +}); diff --git a/src/server/api/slides.ts b/src/server/api/slides.ts index e0a357f..ab9f9ad 100644 --- a/src/server/api/slides.ts +++ b/src/server/api/slides.ts @@ -88,6 +88,8 @@ const slidesShow = async (req: Express.Request, res: Express.Response) => { slides_show_path: slide.slidesShowPath, slide_path: slide.slidePath, published: slide.published, + modified: slide.modified, + is_older_than_remote: slide.isOlderThanRemote, theme: typeof slide.marpFrontmatter.theme === "string" ? slide.marpFrontmatter.theme @@ -96,7 +98,40 @@ const slidesShow = async (req: Express.Request, res: Express.Response) => { res.json(result); }; +const slidesUpdate = async (req: Express.Request, res: Express.Response) => { + const slideId = req.params.id; + const basename: string | null = req.body.basename; + + const slideFileSystemRepo = await getSlideFileSystemRepo(); + const slide = + slideId === "post" && basename + ? await slideFileSystemRepo.loadSlideByBasename(basename) + : await slideFileSystemRepo.loadSlideById(slideId); + + if (!slide) { + res.status(404).json({ + message: "Not found", + }); + return; + } + + const qiitaApi = await getQiitaApiInstance(); + try { + const { slide: responseSlide } = await slideFileSystemRepo.publishSlide( + slide, + qiitaApi, + ); + + res.json({ success: true, uuid: responseSlide.uuid }); + } catch { + res.json({ + success: false, + }); + } +}; + export const SlidesRouter = Router() .get("/", slidesIndex) .post("/", slidesCreate) - .get("/:id", slidesShow); + .get("/:id", slidesShow) + .post("/:id", slidesUpdate); From b61b686f0a4a552ce7ffea489e7ecee2e36a656a Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 15:36:02 +0900 Subject: [PATCH 2/7] Point Colors.surfaceVariant at the CSS variable Qiita defines Qiita defines --color-surfaceVariant, so var(--color-surface-variant) resolved to nothing and left the background transparent. Co-Authored-By: Claude Opus 5.5 --- src/client/lib/variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/lib/variables.ts b/src/client/lib/variables.ts index f1891c8..32a720f 100644 --- a/src/client/lib/variables.ts +++ b/src/client/lib/variables.ts @@ -85,7 +85,7 @@ export const Colors = { // Base color background: "var(--color-background)", surface: "var(--color-surface)", - surfaceVariant: "var(--color-surface-variant)", + surfaceVariant: "var(--color-surfaceVariant)", // Divider divider: "var(--color-divider)", From d3233b3bdc24a6243298f4f9cee3fe8a7671ee07 Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 15:36:08 +0900 Subject: [PATCH 3/7] Show speaker notes under each slide in place of the page number Follow the approved preview design: a 1px divider border replaces the shadow, and speaker notes appear under each page. Co-Authored-By: Claude Opus 5.5 --- .../components/MarpSlideViewer/index.tsx | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/client/components/MarpSlideViewer/index.tsx b/src/client/components/MarpSlideViewer/index.tsx index fffb02a..a35cf9f 100644 --- a/src/client/components/MarpSlideViewer/index.tsx +++ b/src/client/components/MarpSlideViewer/index.tsx @@ -1,5 +1,11 @@ import { css } from "@emotion/react"; -import { Colors, Typography, getSpace } from "../../lib/variables"; +import { + Colors, + LineHeight, + Typography, + Weight, + getSpace, +} from "../../lib/variables"; import { MarpSlideShadowContent } from "../MarpSlideShadowContent"; interface SlidePage { @@ -15,8 +21,6 @@ interface Props { // `slideCss`, not `css`: the Emotion JSX pragma (jsxImportSource) intercepts // any prop literally named `css` on every element, including custom components. export const MarpSlideViewer = ({ pages, slideCss }: Props) => { - const totalPage = pages.length; - return (
{pages.map((page, index) => ( @@ -24,9 +28,16 @@ export const MarpSlideViewer = ({ pages, slideCss }: Props) => {
- - {index + 1} / {totalPage} - + {page.speaker_note.length > 0 && ( +
+

スピーカーノート

+ {page.speaker_note.map((note, noteIndex) => ( +

+ {note} +

+ ))} +
+ )}
))} @@ -43,19 +54,35 @@ const listStyle = css({ const pageWrapperStyle = css({ display: "flex", flexDirection: "column", - gap: getSpace(1 / 2), + gap: getSpace(2), width: "100%", }); const pageStyle = css({ aspectRatio: "16 / 9", - boxShadow: "0 1px 4px rgba(0, 0, 0, 0.2)", + border: `1px solid ${Colors.divider}`, + boxSizing: "border-box", overflow: "hidden", width: "100%", }); -const pageNumberStyle = css({ - alignSelf: "flex-end", - color: Colors.mediumEmphasis, - fontSize: Typography.body2, +const speakerNoteStyle = css({ + backgroundColor: Colors.surfaceVariant, + borderRadius: 8, + display: "flex", + flexDirection: "column", + gap: getSpace(1), + padding: `${getSpace(3 / 2)}px ${getSpace(2)}px`, +}); + +const speakerNoteLabelStyle = css({ + fontSize: Typography.body1, + fontWeight: Weight.bold, + lineHeight: LineHeight.body, +}); + +const speakerNoteBodyStyle = css({ + fontSize: Typography.body1, + lineHeight: LineHeight.body, + whiteSpace: "pre-wrap", }); From e16da7e66204b6a063d517c26deda4285b18406e Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 15:36:22 +0900 Subject: [PATCH 4/7] Drop the footer from the slide presentation mode Co-Authored-By: Claude Opus 5.5 --- src/client/components/MarpSlidePresenter.tsx | 34 +------------------- src/client/pages/slides/show.tsx | 6 +--- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/src/client/components/MarpSlidePresenter.tsx b/src/client/components/MarpSlidePresenter.tsx index e488ba4..598b463 100644 --- a/src/client/components/MarpSlidePresenter.tsx +++ b/src/client/components/MarpSlidePresenter.tsx @@ -5,7 +5,6 @@ import { useEffect, useState, } from "react"; -import { Colors, Typography, getSpace } from "../lib/variables"; import { MarpSlideShadowContent } from "./MarpSlideShadowContent"; const LEFT_KEY = 37; @@ -19,12 +18,11 @@ interface SlidePage { interface Props { pages: SlidePage[]; slideCss: string; - title?: string; } // `slideCss`, not `css`: the Emotion JSX pragma (jsxImportSource) intercepts // any prop literally named `css` on every element, including custom components. -export const MarpSlidePresenter = ({ pages, slideCss, title }: Props) => { +export const MarpSlidePresenter = ({ pages, slideCss }: Props) => { const [currentPageIndex, setCurrentPageIndex] = useState(0); const totalPage = pages.length; @@ -79,14 +77,6 @@ export const MarpSlidePresenter = ({ pages, slideCss, title }: Props) => {
-
- {title} - {totalPage > 0 && ( - - {currentPageIndex + 1} / {totalPage} - - )} -
); }; @@ -102,25 +92,3 @@ const contentStyle = css({ flex: 1, overflow: "auto", }); - -const footerStyle = css({ - alignItems: "center", - backgroundColor: Colors.gray0, - borderTop: `1px solid ${Colors.divider}`, - display: "flex", - fontSize: Typography.body2, - justifyContent: "space-between", - padding: `${getSpace(1)}px ${getSpace(2)}px`, -}); - -const titleStyle = css({ - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const pageCounterStyle = css({ - color: Colors.mediumEmphasis, - flexShrink: 0, - marginLeft: getSpace(2), -}); diff --git a/src/client/pages/slides/show.tsx b/src/client/pages/slides/show.tsx index 6a68a26..49a272c 100644 --- a/src/client/pages/slides/show.tsx +++ b/src/client/pages/slides/show.tsx @@ -71,11 +71,7 @@ export const SlidesShow = () => { if (isPresentationMode) { return slide ? (
- +
) : (
From 71932b985d06c6ff2f8a1ed94d4579e32ae5db0b Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 15:36:27 +0900 Subject: [PATCH 5/7] Split the slide toolbar into slideshow and publish buttons The publish button posts through the new slides API. On mobile both collapse into 48px icon buttons, as in the approved design. Co-Authored-By: Claude Opus 5.5 --- src/client/components/Header.tsx | 110 ++++++++++++++++++++++++++++--- src/client/pages/slides/show.tsx | 8 ++- src/lib/qiita-cli-url.ts | 4 ++ 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/client/components/Header.tsx b/src/client/components/Header.tsx index 9a8d8ce..6bbfae3 100644 --- a/src/client/components/Header.tsx +++ b/src/client/components/Header.tsx @@ -1,7 +1,12 @@ import { css } from "@emotion/react"; import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { apiItemsUpdatePath, itemsShowPath } from "../../lib/qiita-cli-url"; +import { + apiItemsUpdatePath, + apiSlidesUpdatePath, + itemsShowPath, + slidesShowPath, +} from "../../lib/qiita-cli-url"; import { breakpoint, pointerFine } from "../lib/mixins"; import { Colors, Typography, Weight, getSpace } from "../lib/variables"; import { useWindowSize } from "../lib/window-size"; @@ -115,17 +120,66 @@ export const Header = ({ }; export const HeaderSlide = ({ + id, + basename, + isSlidePublishable, + isOlderThanRemote, slidePath, presentPath, handleMobileOpen, }: { + id: string; + basename: string | null; + isSlidePublishable: boolean; + isOlderThanRemote: boolean; slidePath: string; presentPath: string; handleMobileOpen: () => void; }) => { + const navigate = useNavigate(); + const [snackbarMessage, setSnackbarMessage] = + useState(null); const { currentWidth } = useWindowSize(); const mobileSize = currentWidth <= breakpoint.S; + const handlePublish = () => { + if (isOlderThanRemote) { + if ( + !window.confirm( + "このスライドはQiita上のスライドより古い可能性があります。上書きしますか?", + ) + ) { + return; + } + } + + fetch(apiSlidesUpdatePath(id), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ basename: basename }), + }) + .then((response) => response.json()) + .then((data) => { + if (!data.uuid) { + setSnackbarMessage({ + type: "error", + message: "投稿に失敗しました", + }); + + return; + } + + setSnackbarMessage({ + type: "success", + message: "スライドが投稿されました", + }); + + navigate(slidesShowPath(data.uuid)); + }); + }; + return (
{mobileSize ? ( @@ -148,15 +202,32 @@ export const HeaderSlide = ({
)} - - {mobileSize ? "プレゼンモード" : "プレゼンテーションモードで開く"} - slideshow - +
+ + {!mobileSize && "スライドショーを開始"} + slideshow + + +
+ ); }; @@ -239,6 +310,25 @@ const headerButtonStyle = css({ }), }); +const headerActionsStyle = css({ + alignItems: "center", + display: "flex", + gap: getSpace(1), +}); + +const headerGrayButtonStyle = css({ + borderColor: Colors.gray80, + color: Colors.gray80, +}); + +const headerIconButtonStyle = css({ + boxSizing: "border-box", + height: 48, + justifyContent: "center", + padding: 0, + width: 48, +}); + const headerLogoWrapperStyle = css({ display: "flex", }); diff --git a/src/client/pages/slides/show.tsx b/src/client/pages/slides/show.tsx index 49a272c..de4c580 100644 --- a/src/client/pages/slides/show.tsx +++ b/src/client/pages/slides/show.tsx @@ -94,9 +94,15 @@ export const SlidesShow = () => { - {slide ? ( + {id && slide ? ( <> { return `/api/items/${itemId === "show" ? "post" : itemId}`; }; +export const apiSlidesUpdatePath = (slideId: string): string => { + return `/api/slides/${slideId === "show" ? "post" : slideId}`; +}; + export const apiReadmeShowPath = () => { return "/api/readme"; }; From 1d283d401c1d40adccb1bf6231d4a12efce98a3e Mon Sep 17 00:00:00 2001 From: Tomoya Chiba Date: Fri, 25 Sep 2026 19:03:04 +0900 Subject: [PATCH 6/7] Let header buttons inherit the font so their sizes match The publish