diff --git a/src/client/components/Header.tsx b/src/client/components/Header.tsx index 9a8d8ce..3a2159d 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 + + +
+
); }; @@ -220,6 +291,7 @@ const headerButtonStyle = css({ borderRadius: 8, color: Colors.green80, display: "flex", + font: "inherit", fontWeight: Weight.bold, gap: `0 ${getSpace(1 / 2)}px`, padding: `${getSpace(1 / 2)}px ${getSpace(2)}px`, @@ -235,10 +307,30 @@ const headerButtonStyle = css({ ...pointerFine({ "&:hover": { backgroundColor: Colors.gray20, + textDecoration: "none", }, }), }); +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/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/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", }); 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)", diff --git a/src/client/pages/slides/show.tsx b/src/client/pages/slides/show.tsx index 6a68a26..de4c580 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 ? (
- +
) : (
@@ -98,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"; }; 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);