From 4c474d2bdf552ccaab938ef755e6ffad44944724 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 24 Mar 2026 21:57:17 +0900 Subject: [PATCH 1/6] #47 --- .../app/controllers/wiki_assets_controller.rb | 38 +++++++++++++++++++ backend/app/models/wiki_asset.rb | 10 +++++ backend/app/models/wiki_page.rb | 2 + backend/config/routes.rb | 2 + .../20260323192300_create_wiki_assets.rb | 16 ++++++++ backend/db/schema.rb | 17 ++++++++- 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 backend/app/controllers/wiki_assets_controller.rb create mode 100644 backend/app/models/wiki_asset.rb create mode 100644 backend/db/migrate/20260323192300_create_wiki_assets.rb diff --git a/backend/app/controllers/wiki_assets_controller.rb b/backend/app/controllers/wiki_assets_controller.rb new file mode 100644 index 0000000..dfb8cf8 --- /dev/null +++ b/backend/app/controllers/wiki_assets_controller.rb @@ -0,0 +1,38 @@ +require 'digest' + + +class WikiAssetsController < ApplicationController + def index + page_id = params[:wiki_page_id].to_i + page = WikiPage.find_by(id: page_id) + return head :not_found unless page + + render json: page.assets + end + + def create + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + wiki_page_id = params[:wiki_page_id].to_i + page = WikiPage.find_by(id: wiki_page_id) + return head :not_found unless page + + file = params[:file] + return head :bad_request if file.blank? + + page.with_lock do + no = page.next_asset_no + alt_text = params[:alt_text].presence + sha256 = Digest::SHA256.file(file.tempfile.path).digest + + asset = WikiAsset.new(wiki_page_id:, no:, alt_text:, sha256:, created_by_user: current_user) + asset.file.attach(file) + asset.save! + + page.update!(next_asset_no: no + 1) + end + + render json: asset + end +end diff --git a/backend/app/models/wiki_asset.rb b/backend/app/models/wiki_asset.rb new file mode 100644 index 0000000..4fb5d66 --- /dev/null +++ b/backend/app/models/wiki_asset.rb @@ -0,0 +1,10 @@ +class WikiAsset < ApplicationRecord + self.primary_key = :wiki_page_id, :no + + belongs_to :wiki_page + belongs_to :created_by_user, class_name: 'User' + + has_one_attached :file + + validates :file, presence: true +end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 1573127..b752599 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord foreign_key: :redirect_page_id, dependent: :nullify + has_many :assets, class_name: 'WikiAsset', dependent: :destroy + belongs_to :tag_name validates :tag_name, presence: true diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 57eb470..e53bef5 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -41,6 +41,8 @@ Rails.application.routes.draw do get :exists get :diff end + + resources :assets, controller: :wiki_assets, only: [:index, :create] end resources :posts, only: [:index, :show, :create, :update] do diff --git a/backend/db/migrate/20260323192300_create_wiki_assets.rb b/backend/db/migrate/20260323192300_create_wiki_assets.rb new file mode 100644 index 0000000..1a4e875 --- /dev/null +++ b/backend/db/migrate/20260323192300_create_wiki_assets.rb @@ -0,0 +1,16 @@ +class CreateWikiAssets < ActiveRecord::Migration[8.0] + def change + create_table :wiki_assets, primary_key: [:wiki_page_id, :no] do |t| + t.references :wiki_page, null: false, foreign_key: true, index: false + t.integer :no, null: false + t.string :alt_text + t.column :sha256, 'binary(32)', null: false + t.references :created_by_user, null: false, foreign_key: { to_table: :users } + t.timestamps + end + + add_index :wiki_assets, [:wiki_page_id, :sha256], unique: true + + add_column :wiki_pages, :next_asset_no, :integer, null: false, default: 1 + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 6a2096b..7dbc388 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do +ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -239,6 +239,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do t.datetime "updated_at", null: false end + create_table "wiki_assets", primary_key: ["wiki_page_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.integer "no", null: false + t.string "alt_text" + t.binary "sha256", limit: 32, null: false + t.bigint "created_by_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true + end + create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -254,6 +266,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" + t.integer "next_asset_no", default: 1, null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true @@ -320,6 +333,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" + add_foreign_key "wiki_assets", "users", column: "created_by_user_id" + add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" -- 2.34.1 From 04b01bf1c622211b2799e42ed2f4de062d10209b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 25 Mar 2026 00:39:07 +0900 Subject: [PATCH 2/6] #47 --- .../app/controllers/wiki_pages_controller.rb | 6 +- frontend/src/components/WikiEditForm.tsx | 57 +++++++++++++++++++ frontend/src/pages/wiki/WikiEditPage.tsx | 38 +++---------- frontend/src/pages/wiki/WikiNewPage.tsx | 48 +++++----------- 4 files changed, 81 insertions(+), 68 deletions(-) create mode 100644 frontend/src/components/WikiEditForm.tsx diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index dc6c47f..e19557d 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -96,7 +96,7 @@ class WikiPagesController < ApplicationController message = params[:message].presence Wiki::Commit.content!(page:, body:, created_user: current_user, message:) - render json: WikiPageRepr.base(page), status: :created + render json: WikiPageRepr.base(page).merge(body:), status: :created else render json: { errors: page.errors.full_messages }, status: :unprocessable_entity @@ -107,7 +107,7 @@ class WikiPagesController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - title = params[:title]&.strip + title = params[:title].to_s.strip body = params[:body].to_s return head :unprocessable_entity if title.blank? || body.blank? @@ -126,7 +126,7 @@ class WikiPagesController < ApplicationController message:, base_revision_id:) - head :ok + render json: WikiPageRepr.base(page).merge(body:), status: :created end def search diff --git a/frontend/src/components/WikiEditForm.tsx b/frontend/src/components/WikiEditForm.tsx new file mode 100644 index 0000000..14cd4da --- /dev/null +++ b/frontend/src/components/WikiEditForm.tsx @@ -0,0 +1,57 @@ +import MarkdownIt from 'markdown-it' +import { useEffect, useState } from 'react' +import MdEditor from 'react-markdown-editor-lite' + +import Label from '@/components/common/Label' + +import type { FC } from 'react' + +const mdParser = new MarkdownIt + +type Props = { + title: string + body: string + onSubmit: (title: string, body: string) => void + forEdit?: boolean } + + +export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) => { + const [title, setTitle] = useState (initTitle) + const [body, setBody] = useState (initBody) + + useEffect (() => { + setTitle (initTitle) + setBody (initBody) + }, [initTitle, initBody]) + + return ( + <> + {/* タイトル */} + {/* TODO: タグ補完 */} +
+ + setTitle (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* 本文 */} +
+ + mdParser.render (text)} + onChange={({ text }) => setBody (text)}/> +
+ + {/* 送信 */} + + ) +}) satisfies FC diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 0fcc2b0..cef9c4a 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -1,10 +1,9 @@ import { useQueryClient } from '@tanstack/react-query' -import MarkdownIt from 'markdown-it' import { useEffect, useState } from 'react' import { Helmet } from 'react-helmet-async' -import MdEditor from 'react-markdown-editor-lite' import { useParams, useNavigate } from 'react-router-dom' +import WikiEditForm from '@/components/WikiEditForm' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' @@ -18,8 +17,6 @@ import type { FC } from 'react' import type { User, WikiPage } from '@/types' -const mdParser = new MarkdownIt - type Props = { user: User | null } @@ -37,7 +34,7 @@ export default (({ user }: Props) => { const [loading, setLoading] = useState (true) const [title, setTitle] = useState ('') - const handleSubmit = async () => { + const handleSubmit = async (title: string, body: string) => { const formData = new FormData () formData.append ('title', title) formData.append ('body', body) @@ -77,32 +74,11 @@ export default (({ user }: Props) => {

Wiki ページを編輯

{loading ? 'Loading...' : ( - <> - {/* タイトル */} - {/* TODO: タグ補完 */} -
- - setTitle (e.target.value)} - className="w-full border p-2 rounded"/> -
- - {/* 本文 */} -
- - mdParser.render (text)} - onChange={({ text }) => setBody (text)}/> -
- - {/* 送信 */} - - )} + )} ) }) satisfies FC diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index 54a76d1..307b1a1 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -1,21 +1,19 @@ -import MarkdownIt from 'markdown-it' -import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { Helmet } from 'react-helmet-async' -import MdEditor from 'react-markdown-editor-lite' import { useLocation, useNavigate } from 'react-router-dom' +import WikiEditForm from '@/components/WikiEditForm' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' +import { wikiKeys } from '@/lib/queryKeys' import Forbidden from '@/pages/Forbidden' import 'react-markdown-editor-lite/lib/index.css' import type { User, WikiPage } from '@/types' -const mdParser = new MarkdownIt - type Props = { user: User | null } @@ -26,13 +24,12 @@ export default ({ user }: Props) => { const location = useLocation () const navigate = useNavigate () + const qc = useQueryClient () + const query = new URLSearchParams (location.search) const titleQuery = query.get ('title') ?? '' - const [title, setTitle] = useState (titleQuery) - const [body, setBody] = useState ('') - - const handleSubmit = async () => { + const handleSubmit = async (title: string, body: string) => { const formData = new FormData formData.append ('title', title) formData.append ('body', body) @@ -40,7 +37,10 @@ export default ({ user }: Props) => { try { const data = await apiPost ('/wiki', formData, - { headers: { 'Content-Type': 'multipart/form-data' } }) + { headers: { 'Content-Type': 'multipart/form-data' } }) + qc.setQueryData (wikiKeys.show (data.title, { }), + (prev: WikiPage) => ({ ...prev, title: data.title, body: data.body })) + qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ title: '投稿成功!' }) navigate (`/wiki/${ data.title }`) } @@ -58,30 +58,10 @@ export default ({ user }: Props) => {

新規 Wiki ページ

- {/* タイトル */} - {/* TODO: タグ補完 */} -
- - setTitle (e.target.value)} - className="w-full border p-2 rounded"/> -
- - {/* 本文 */} -
- - mdParser.render (text)} - onChange={({ text }) => setBody (text)}/> -
- - {/* 送信 */} - +
) } -- 2.34.1 From ef6219dcb1cce36cebe6f7d80c7f27f1e9bd7453 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Mar 2026 00:01:29 +0900 Subject: [PATCH 3/6] #47 --- .../app/controllers/wiki_pages_controller.rb | 2 +- frontend/src/components/WikiBody.tsx | 32 +------- frontend/src/components/WikiEditForm.tsx | 24 +++++- frontend/src/components/WikiMarkdown.tsx | 77 +++++++++++++++++++ frontend/src/pages/wiki/WikiEditPage.tsx | 4 +- frontend/src/pages/wiki/WikiNewPage.tsx | 2 - 6 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/WikiMarkdown.tsx diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index e19557d..9e264fb 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -126,7 +126,7 @@ class WikiPagesController < ApplicationController message:, base_revision_id:) - render json: WikiPageRepr.base(page).merge(body:), status: :created + render json: WikiPageRepr.base(page).merge(body:) end def search diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 50316b2..86381e5 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown' import remarkGFM from 'remark-gfm' import PrefetchLink from '@/components/PrefetchLink' +import WikiMarkdown from '@/components/WikiMarkdown' import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import { wikiKeys } from '@/lib/queryKeys' @@ -16,33 +17,6 @@ import type { Components } from 'react-markdown' type Props = { title: string body?: string } -const mdComponents = { h1: ({ children }) => {children}, - h2: ({ children }) => {children}, - ol: ({ children }) =>
    {children}
, - ul: ({ children }) =>
    {children}
, - a: (({ href, children }) => ( - ['/', '.'].some (e => href?.startsWith (e)) - ? {children} - : ( - - {children} - ))) } as const satisfies Components - -export default (({ title, body }: Props) => { - const { data } = useQuery ({ - enabled: Boolean (body), - queryKey: wikiKeys.index ({ }), - queryFn: () => fetchWikiPages ({ }) }) - const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length) - - const remarkPlugins = useMemo ( - () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) - - return ( - - {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} - ) -}) satisfies FC +export default (({ title, body }: Props) => + ) satisfies FC diff --git a/frontend/src/components/WikiEditForm.tsx b/frontend/src/components/WikiEditForm.tsx index 14cd4da..be64469 100644 --- a/frontend/src/components/WikiEditForm.tsx +++ b/frontend/src/components/WikiEditForm.tsx @@ -12,10 +12,12 @@ type Props = { title: string body: string onSubmit: (title: string, body: string) => void - forEdit?: boolean } + id?: number | null } -export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) => { +export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { + const forEdit = id != null + const [title, setTitle] = useState (initTitle) const [body, setBody] = useState (initBody) @@ -24,6 +26,21 @@ export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) setBody (initBody) }, [initTitle, initBody]) + const handleImageUpload = async (file: File) => { + if (!(forEdit)) + throw new Error ('画像は Wiki 作成前に追加することができません.') + + const formData = new FormData + formData.append ('file', file) + + const asset = await apiPost ( + `/wiki/${ id }/assets`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } }) + + return `{{img:${ asset.no }}}` + } + return ( <> {/* タイトル */} @@ -44,7 +61,8 @@ export default (({ title: initTitle, body: initBody, onSubmit, forEdit }: Props) value={body} style={{ height: '500px' }} renderHTML={text => mdParser.render (text)} - onChange={({ text }) => setBody (text)}/> + onChange={({ text }) => setBody (text)} + onImageUpload={handleImageUpload}/> {/* 送信 */} diff --git a/frontend/src/components/WikiMarkdown.tsx b/frontend/src/components/WikiMarkdown.tsx new file mode 100644 index 0000000..edb23e2 --- /dev/null +++ b/frontend/src/components/WikiMarkdown.tsx @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGFM from 'remark-gfm' + +import PrefetchLink from '@/components/PrefetchLink' +import SectionTitle from '@/components/common/SectionTitle' +import SubsectionTitle from '@/components/common/SubsectionTitle' +import { wikiKeys } from '@/lib/queryKeys' +import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' +import { fetchWikiPages } from '@/lib/wiki' + +import type { FC } from 'react' +import type { Components } from 'react-markdown' + +type Props = { + title?: string + body: string + preview?: boolean } + + +const makeComponents = (preview = false) => ( + { h1: ({ children }) => {children}, + h2: ({ children }) => {children}, + ol: ({ children }) =>
    {children}
, + ul: ({ children }) =>
    {children}
, + a: ({ href, children }) => { + if (!(href)) + return <>{children} + + if (!(preview) && ['/', '.'].some (e => href.startsWith (e))) + return {children} + + const ext = /^(?:https?:)?\/\//.test (href) + + return ( + + {children} + ) + }, + img: (({ src, alt }) => ( + {alt)), + } as const satisfies Components) + + +export default (({ title, body, preview = false }: Props) => { + const { data } = useQuery ({ + queryKey: wikiKeys.index ({ }), + queryFn: () => fetchWikiPages ({ }) }) + + const pageNames = useMemo ( + () => (data ?? []).map ((page) => page.title).sort ((a, b) => b.length - a.length), + [data]) + + const remarkPlugins = useMemo ( + () => [() => remarkWikiAutoLink (pageNames), remarkGFM], + [pageNames]) + + const components = useMemo ( + () => makeComponents (preview), + [preview]) + + return ( + + {body + || (title + ? ('このページは存在しません。' + +`[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`) + : '')} + ) +}) satisfies FC diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index cef9c4a..75c9742 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -43,8 +43,6 @@ export default (({ user }: Props) => { { await apiPut (`/wiki/${ id }`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - qc.setQueryData (wikiKeys.show (title, { }), - (prev: WikiPage) => ({ ...prev, title, body })) qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ title: '投稿成功!' }) navigate (`/wiki/${ title }`) @@ -78,7 +76,7 @@ export default (({ user }: Props) => { title={title} body={body} onSubmit={handleSubmit} - forEdit/>)} + id={Number (id)}/>)} ) }) satisfies FC diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index 307b1a1..38355ca 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -38,8 +38,6 @@ export default ({ user }: Props) => { { const data = await apiPost ('/wiki', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - qc.setQueryData (wikiKeys.show (data.title, { }), - (prev: WikiPage) => ({ ...prev, title: data.title, body: data.body })) qc.invalidateQueries ({ queryKey: wikiKeys.root }) toast ({ title: '投稿成功!' }) navigate (`/wiki/${ data.title }`) -- 2.34.1 From fb275b476369d3754f939722c2cd7e0a04285d62 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 26 Mar 2026 23:52:44 +0900 Subject: [PATCH 4/6] #47 --- backend/app/controllers/wiki_assets_controller.rb | 2 +- backend/app/models/wiki_asset.rb | 4 ++++ frontend/src/components/WikiBody.tsx | 12 ------------ frontend/src/components/WikiEditForm.tsx | 7 ++++--- frontend/src/types.ts | 5 +++++ 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/backend/app/controllers/wiki_assets_controller.rb b/backend/app/controllers/wiki_assets_controller.rb index dfb8cf8..eca800e 100644 --- a/backend/app/controllers/wiki_assets_controller.rb +++ b/backend/app/controllers/wiki_assets_controller.rb @@ -33,6 +33,6 @@ class WikiAssetsController < ApplicationController page.update!(next_asset_no: no + 1) end - render json: asset + render json: asset.as_json(methods: [:url]) end end diff --git a/backend/app/models/wiki_asset.rb b/backend/app/models/wiki_asset.rb index 4fb5d66..52321cf 100644 --- a/backend/app/models/wiki_asset.rb +++ b/backend/app/models/wiki_asset.rb @@ -7,4 +7,8 @@ class WikiAsset < ApplicationRecord has_one_attached :file validates :file, presence: true + + def url + Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true) + end end diff --git a/frontend/src/components/WikiBody.tsx b/frontend/src/components/WikiBody.tsx index 86381e5..a071e79 100644 --- a/frontend/src/components/WikiBody.tsx +++ b/frontend/src/components/WikiBody.tsx @@ -1,18 +1,6 @@ -import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGFM from 'remark-gfm' - -import PrefetchLink from '@/components/PrefetchLink' import WikiMarkdown from '@/components/WikiMarkdown' -import SectionTitle from '@/components/common/SectionTitle' -import SubsectionTitle from '@/components/common/SubsectionTitle' -import { wikiKeys } from '@/lib/queryKeys' -import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' -import { fetchWikiPages } from '@/lib/wiki' import type { FC } from 'react' -import type { Components } from 'react-markdown' type Props = { title: string body?: string } diff --git a/frontend/src/components/WikiEditForm.tsx b/frontend/src/components/WikiEditForm.tsx index be64469..41556ee 100644 --- a/frontend/src/components/WikiEditForm.tsx +++ b/frontend/src/components/WikiEditForm.tsx @@ -1,12 +1,13 @@ -import MarkdownIt from 'markdown-it' import { useEffect, useState } from 'react' import MdEditor from 'react-markdown-editor-lite' +import WikiMarkdown from '@/components/WikiMarkdown' import Label from '@/components/common/Label' +import { apiPost } from '@/lib/api' import type { FC } from 'react' -const mdParser = new MarkdownIt +import type { WikiAsset } from '@/types' type Props = { title: string @@ -60,7 +61,7 @@ export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { mdParser.render (text)} + renderHTML={text => } onChange={({ text }) => setBody (text)} onImageUpload={handleImageUpload}/> diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 36f8b32..bc676e2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -149,6 +149,11 @@ export type User = { export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior] +export type WikiAsset = { + wikiPageId: number + no: number + url: string } + export type WikiPage = { id: number title: string -- 2.34.1 From e8be071064a757da339989dc161fac5cf48233ae Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 27 Mar 2026 00:32:28 +0900 Subject: [PATCH 5/6] #47 --- backend/app/controllers/wiki_assets_controller.rb | 3 ++- backend/app/models/wiki_asset.rb | 2 -- backend/db/migrate/20260323192300_create_wiki_assets.rb | 3 ++- backend/db/schema.rb | 3 ++- frontend/src/components/WikiEditForm.tsx | 2 +- frontend/src/components/WikiMarkdown.tsx | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/backend/app/controllers/wiki_assets_controller.rb b/backend/app/controllers/wiki_assets_controller.rb index eca800e..c3861c2 100644 --- a/backend/app/controllers/wiki_assets_controller.rb +++ b/backend/app/controllers/wiki_assets_controller.rb @@ -21,6 +21,7 @@ class WikiAssetsController < ApplicationController file = params[:file] return head :bad_request if file.blank? + asset = nil page.with_lock do no = page.next_asset_no alt_text = params[:alt_text].presence @@ -33,6 +34,6 @@ class WikiAssetsController < ApplicationController page.update!(next_asset_no: no + 1) end - render json: asset.as_json(methods: [:url]) + render json: asset.as_json(only: [:wiki_page_id, :no], methods: [:url]) end end diff --git a/backend/app/models/wiki_asset.rb b/backend/app/models/wiki_asset.rb index 52321cf..c772679 100644 --- a/backend/app/models/wiki_asset.rb +++ b/backend/app/models/wiki_asset.rb @@ -1,6 +1,4 @@ class WikiAsset < ApplicationRecord - self.primary_key = :wiki_page_id, :no - belongs_to :wiki_page belongs_to :created_by_user, class_name: 'User' diff --git a/backend/db/migrate/20260323192300_create_wiki_assets.rb b/backend/db/migrate/20260323192300_create_wiki_assets.rb index 1a4e875..97e0c43 100644 --- a/backend/db/migrate/20260323192300_create_wiki_assets.rb +++ b/backend/db/migrate/20260323192300_create_wiki_assets.rb @@ -1,6 +1,6 @@ class CreateWikiAssets < ActiveRecord::Migration[8.0] def change - create_table :wiki_assets, primary_key: [:wiki_page_id, :no] do |t| + create_table :wiki_assets do |t| t.references :wiki_page, null: false, foreign_key: true, index: false t.integer :no, null: false t.string :alt_text @@ -10,6 +10,7 @@ class CreateWikiAssets < ActiveRecord::Migration[8.0] end add_index :wiki_assets, [:wiki_page_id, :sha256], unique: true + add_index :wiki_assets, [:wiki_page_id, :no], unique: true add_column :wiki_pages, :next_asset_no, :integer, null: false, default: 1 end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 7dbc388..82e8fe2 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -239,7 +239,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.datetime "updated_at", null: false end - create_table "wiki_assets", primary_key: ["wiki_page_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "wiki_page_id", null: false t.integer "no", null: false t.string "alt_text" @@ -248,6 +248,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true end diff --git a/frontend/src/components/WikiEditForm.tsx b/frontend/src/components/WikiEditForm.tsx index 41556ee..c989622 100644 --- a/frontend/src/components/WikiEditForm.tsx +++ b/frontend/src/components/WikiEditForm.tsx @@ -39,7 +39,7 @@ export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - return `{{img:${ asset.no }}}` + return asset.url } return ( diff --git a/frontend/src/components/WikiMarkdown.tsx b/frontend/src/components/WikiMarkdown.tsx index edb23e2..dc19c01 100644 --- a/frontend/src/components/WikiMarkdown.tsx +++ b/frontend/src/components/WikiMarkdown.tsx @@ -43,7 +43,7 @@ const makeComponents = (preview = false) => ( img: (({ src, alt }) => ( {alt)), + className="max-w-[240px] max-h-[320px]"/>)), } as const satisfies Components) -- 2.34.1 From e40f7a36201df633d968cdf0c1dd0146695b1352 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 27 Mar 2026 01:01:47 +0900 Subject: [PATCH 6/6] #47 --- .../app/controllers/wiki_assets_controller.rb | 4 +- .../app/representations/wiki_asset_repr.rb | 16 ++ backend/spec/requests/wiki_assets_spec.rb | 191 ++++++++++++++++++ backend/spec/requests/wiki_spec.rb | 24 +-- 4 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 backend/app/representations/wiki_asset_repr.rb create mode 100644 backend/spec/requests/wiki_assets_spec.rb diff --git a/backend/app/controllers/wiki_assets_controller.rb b/backend/app/controllers/wiki_assets_controller.rb index c3861c2..e22f3c2 100644 --- a/backend/app/controllers/wiki_assets_controller.rb +++ b/backend/app/controllers/wiki_assets_controller.rb @@ -7,7 +7,7 @@ class WikiAssetsController < ApplicationController page = WikiPage.find_by(id: page_id) return head :not_found unless page - render json: page.assets + render json: WikiAssetRepr.many(page.assets) end def create @@ -34,6 +34,6 @@ class WikiAssetsController < ApplicationController page.update!(next_asset_no: no + 1) end - render json: asset.as_json(only: [:wiki_page_id, :no], methods: [:url]) + render json: WikiAssetRepr.base(asset) end end diff --git a/backend/app/representations/wiki_asset_repr.rb b/backend/app/representations/wiki_asset_repr.rb new file mode 100644 index 0000000..6a1e90f --- /dev/null +++ b/backend/app/representations/wiki_asset_repr.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + + +module WikiAssetRepr + BASE = { only: [:wiki_page_id, :no], methods: [:url] }.freeze + + module_function + + def base wiki_asset + wiki_asset.as_json(BASE) + end + + def many wiki_assets + wiki_assets.map { |a| base(a) } + end +end diff --git a/backend/spec/requests/wiki_assets_spec.rb b/backend/spec/requests/wiki_assets_spec.rb new file mode 100644 index 0000000..d0afbbf --- /dev/null +++ b/backend/spec/requests/wiki_assets_spec.rb @@ -0,0 +1,191 @@ +require 'digest' +require 'rails_helper' +require 'stringio' + + +RSpec.describe 'WikiAssets API', type: :request do + def dummy_upload(content = 'dummy-image', filename: 'dummy.png', content_type: 'image/png') + Rack::Test::UploadedFile.new(StringIO.new(content), + content_type, + original_filename: filename) + end + + let(:member) { create(:user, :member, name: 'member user') } + let(:guest) { create(:user, name: 'guest user') } + + let!(:tag_name) { TagName.create!(name: 'spec_wiki_asset_page') } + let!(:page) do + WikiPage.create!(tag_name: tag_name, created_user: member, updated_user: member).tap do |p| + Wiki::Commit.content!(page: p, body: 'init', created_user: member, message: 'init') + end + end + + describe 'GET /wiki/:wiki_page_id/assets' do + subject(:do_request) do + get "/wiki/#{wiki_page_id}/assets" + end + + let(:wiki_page_id) { page.id } + + let!(:asset) do + WikiAsset.new(wiki_page: page, + no: 1, + alt_text: 'spec alt', + sha256: Digest::SHA256.digest('asset-1'), + created_by_user: member).tap do |record| + record.file.attach(dummy_upload('asset-1')) + record.save! + end + end + + context 'when wiki page exists' do + it 'returns assets for the page' do + do_request + + expect(response).to have_http_status(:ok) + expect(json).to be_an(Array) + expect(json.size).to eq(1) + + expect(json.first).to include( + 'wiki_page_id' => page.id, + 'no' => 1) + end + + it 'does not include assets from other pages' do + other_tag_name = TagName.create!(name: 'spec_other_wiki_asset_page') + other_page = WikiPage.create!(tag_name: other_tag_name, + created_user: member, + updated_user: member) + Wiki::Commit.content!(page: other_page, body: 'other', created_user: member, message: 'other') + + WikiAsset.new(wiki_page: other_page, + no: 1, + alt_text: 'other alt', + sha256: Digest::SHA256.digest('asset-2'), + created_by_user: member).tap do |record| + record.file.attach(dummy_upload('asset-2', filename: 'other.png')) + record.save! + end + + do_request + + expect(response).to have_http_status(:ok) + expect(json.size).to eq(1) + expect(json.first['wiki_page_id']).to eq(page.id) + end + end + + context 'when wiki page does not exist' do + let(:wiki_page_id) { 999_999_999 } + + it 'returns 404' do + do_request + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /wiki/:wiki_page_id/assets' do + subject(:do_request) do + post "/wiki/#{wiki_page_id}/assets", params: params + end + + let(:wiki_page_id) { page.id } + let(:params) do + { file: dummy_upload(upload_content), + alt_text: 'uploaded alt' } + end + let(:upload_content) { 'uploaded-image-binary' } + + context 'when not logged in' do + it 'returns 401' do + sign_out + do_request + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when logged in but not member' do + it 'returns 403' do + sign_in_as(guest) + do_request + expect(response).to have_http_status(:forbidden) + end + end + + context 'when wiki page does not exist' do + let(:wiki_page_id) { 999_999_999 } + + it 'returns 404' do + sign_in_as(member) + do_request + expect(response).to have_http_status(:not_found) + end + end + + context 'when file is blank' do + let(:params) { { alt_text: 'uploaded alt' } } + + it 'returns 400' do + sign_in_as(member) + do_request + expect(response).to have_http_status(:bad_request) + end + end + + context 'when success' do + before do + sign_in_as(member) + end + + it 'creates asset, attaches file, increments next_asset_no, and returns json' do + expect { do_request } + .to change(WikiAsset, :count).by(1) + + expect(response).to have_http_status(:ok) + + asset = WikiAsset.order(:id).last + expect(asset.wiki_page_id).to eq(page.id) + expect(asset.no).to eq(1) + expect(asset.alt_text).to eq('uploaded alt') + expect(asset.sha256).to eq(Digest::SHA256.digest(upload_content)) + expect(asset.created_by_user_id).to eq(member.id) + expect(asset.file).to be_attached + expect(asset.file.download).to eq(upload_content) + + expect(page.reload.next_asset_no).to eq(2) + + expect(json).to include( + 'wiki_page_id' => page.id, + 'no' => 1, + 'url' => asset.url + ) + end + + it 'uses the next page-local number when assets already exist' do + existing = WikiAsset.new(wiki_page: page, + no: 1, + alt_text: 'existing alt', + sha256: Digest::SHA256.digest('existing'), + created_by_user: member) + existing.file.attach(dummy_upload('existing', filename: 'existing.png')) + existing.save! + page.update!(next_asset_no: 2) + + do_request + + expect(response).to have_http_status(:ok) + + asset = WikiAsset.order(:id).last + expect(asset.no).to eq(2) + expect(page.reload.next_asset_no).to eq(3) + + expect(json).to include( + 'wiki_page_id' => page.id, + 'no' => 2, + 'url' => asset.url + ) + end + end + end +end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 4541501..bc92f1c 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -97,13 +97,14 @@ RSpec.describe 'Wiki API', type: :request do post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, headers: auth_headers(member) end - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) expect(response).to have_http_status(:created) page_id = json.fetch('id') expect(json.fetch('title')).to eq('TestPage') + expect(json.fetch('body')).to eq("a\nb\nc") page = WikiPage.find(page_id) rev = page.current_revision @@ -111,30 +112,11 @@ RSpec.describe 'Wiki API', type: :request do expect(rev).to be_content expect(rev.message).to eq('init') - # body が復元できること expect(page.body).to eq("a\nb\nc") - - # 行数とリレーションの整合 expect(rev.lines_count).to eq(3) expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) end - - it 'reuses existing WikiLine rows by sha256' do - # 先に同じ行を作っておく - WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) - - post endpoint, - params: { title: 'Reuse', body: "a\na" }, - headers: auth_headers(member) - - page = WikiPage.find(JSON.parse(response.body).fetch('id')) - rev = page.current_revision - expect(rev.lines_count).to eq(2) - - # "a" の WikiLine が増殖しない(1行のはず) - expect(WikiLine.where(body: 'a').count).to eq(1) - end end end -- 2.34.1