feature/047 into main
| @@ -0,0 +1,39 @@ | |||||
| 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: WikiAssetRepr.many(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? | |||||
| asset = nil | |||||
| 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: WikiAssetRepr.base(asset) | |||||
| end | |||||
| end | |||||
| @@ -96,7 +96,7 @@ class WikiPagesController < ApplicationController | |||||
| message = params[:message].presence | message = params[:message].presence | ||||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | 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 | else | ||||
| render json: { errors: page.errors.full_messages }, | render json: { errors: page.errors.full_messages }, | ||||
| status: :unprocessable_entity | status: :unprocessable_entity | ||||
| @@ -107,7 +107,7 @@ class WikiPagesController < ApplicationController | |||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.gte_member? | return head :forbidden unless current_user.gte_member? | ||||
| title = params[:title]&.strip | |||||
| title = params[:title].to_s.strip | |||||
| body = params[:body].to_s | body = params[:body].to_s | ||||
| return head :unprocessable_entity if title.blank? || body.blank? | return head :unprocessable_entity if title.blank? || body.blank? | ||||
| @@ -126,7 +126,7 @@ class WikiPagesController < ApplicationController | |||||
| message:, | message:, | ||||
| base_revision_id:) | base_revision_id:) | ||||
| head :ok | |||||
| render json: WikiPageRepr.base(page).merge(body:) | |||||
| end | end | ||||
| def search | def search | ||||
| @@ -0,0 +1,12 @@ | |||||
| class WikiAsset < ApplicationRecord | |||||
| belongs_to :wiki_page | |||||
| belongs_to :created_by_user, class_name: 'User' | |||||
| has_one_attached :file | |||||
| validates :file, presence: true | |||||
| def url | |||||
| Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true) | |||||
| end | |||||
| end | |||||
| @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord | |||||
| foreign_key: :redirect_page_id, | foreign_key: :redirect_page_id, | ||||
| dependent: :nullify | dependent: :nullify | ||||
| has_many :assets, class_name: 'WikiAsset', dependent: :destroy | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| @@ -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 | |||||
| @@ -41,6 +41,8 @@ Rails.application.routes.draw do | |||||
| get :exists | get :exists | ||||
| get :diff | get :diff | ||||
| end | end | ||||
| resources :assets, controller: :wiki_assets, only: [:index, :create] | |||||
| end | end | ||||
| resources :posts, only: [:index, :show, :create, :update] do | resources :posts, only: [:index, :show, :create, :update] do | ||||
| @@ -0,0 +1,17 @@ | |||||
| class CreateWikiAssets < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| 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 | |||||
| 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_index :wiki_assets, [:wiki_page_id, :no], unique: true | |||||
| add_column :wiki_pages, :next_asset_no, :integer, null: false, default: 1 | |||||
| end | |||||
| end | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # 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| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -239,6 +239,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| end | end | ||||
| 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" | |||||
| 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", "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 | |||||
| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "sha256", limit: 64, null: false | t.string "sha256", limit: 64, null: false | ||||
| t.text "body", null: false | t.text "body", null: false | ||||
| @@ -254,6 +267,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.datetime "discarded_at" | 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 ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | ||||
| t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" | 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 | t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true | ||||
| @@ -320,6 +334,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||||
| add_foreign_key "user_ips", "users" | add_foreign_key "user_ips", "users" | ||||
| add_foreign_key "user_post_views", "posts" | add_foreign_key "user_post_views", "posts" | ||||
| add_foreign_key "user_post_views", "users" | 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", "tag_names" | ||||
| add_foreign_key "wiki_pages", "users", column: "created_user_id" | add_foreign_key "wiki_pages", "users", column: "created_user_id" | ||||
| add_foreign_key "wiki_pages", "users", column: "updated_user_id" | add_foreign_key "wiki_pages", "users", column: "updated_user_id" | ||||
| @@ -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 | |||||
| @@ -104,6 +104,7 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| page_id = json.fetch('id') | page_id = json.fetch('id') | ||||
| expect(json.fetch('title')).to eq('TestPage') | expect(json.fetch('title')).to eq('TestPage') | ||||
| expect(json.fetch('body')).to eq("a\nb\nc") | |||||
| page = WikiPage.find(page_id) | page = WikiPage.find(page_id) | ||||
| rev = page.current_revision | rev = page.current_revision | ||||
| @@ -111,30 +112,11 @@ RSpec.describe 'Wiki API', type: :request do | |||||
| expect(rev).to be_content | expect(rev).to be_content | ||||
| expect(rev.message).to eq('init') | expect(rev.message).to eq('init') | ||||
| # body が復元できること | |||||
| expect(page.body).to eq("a\nb\nc") | expect(page.body).to eq("a\nb\nc") | ||||
| # 行数とリレーションの整合 | |||||
| expect(rev.lines_count).to eq(3) | expect(rev.lines_count).to eq(3) | ||||
| expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) | 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]) | expect(rev.wiki_lines.pluck(:body)).to match_array(%w[a b c]) | ||||
| end | 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 | ||||
| end | end | ||||
| @@ -1,48 +1,10 @@ | |||||
| 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 WikiMarkdown from '@/components/WikiMarkdown' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { Components } from 'react-markdown' | |||||
| type Props = { title: string | type Props = { title: string | ||||
| body?: string } | body?: string } | ||||
| const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||||
| a: (({ href, children }) => ( | |||||
| ['/', '.'].some (e => href?.startsWith (e)) | |||||
| ? <PrefetchLink to={href!}>{children}</PrefetchLink> | |||||
| : ( | |||||
| <a href={href} | |||||
| target="_blank" | |||||
| rel="noopener noreferrer"> | |||||
| {children} | |||||
| </a>))) } 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 ( | |||||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||||
| </ReactMarkdown>) | |||||
| }) satisfies FC<Props> | |||||
| export default (({ title, body }: Props) => | |||||
| <WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props> | |||||
| @@ -0,0 +1,76 @@ | |||||
| 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' | |||||
| import type { WikiAsset } from '@/types' | |||||
| type Props = { | |||||
| title: string | |||||
| body: string | |||||
| onSubmit: (title: string, body: string) => void | |||||
| id?: number | null } | |||||
| export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { | |||||
| const forEdit = id != null | |||||
| const [title, setTitle] = useState<string> (initTitle) | |||||
| const [body, setBody] = useState<string> (initBody) | |||||
| useEffect (() => { | |||||
| setTitle (initTitle) | |||||
| 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<WikiAsset> ( | |||||
| `/wiki/${ id }/assets`, | |||||
| formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| return asset.url | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| {/* タイトル */} | |||||
| {/* TODO: タグ補完 */} | |||||
| <div> | |||||
| <Label>タイトル</Label> | |||||
| <input | |||||
| type="text" | |||||
| value={title} | |||||
| onChange={e => setTitle (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* 本文 */} | |||||
| <div> | |||||
| <Label>本文</Label> | |||||
| <MdEditor | |||||
| value={body} | |||||
| style={{ height: '500px' }} | |||||
| renderHTML={text => <WikiMarkdown body={text} preview/>} | |||||
| onChange={({ text }) => setBody (text)} | |||||
| onImageUpload={handleImageUpload}/> | |||||
| </div> | |||||
| {/* 送信 */} | |||||
| <button | |||||
| onClick={() => onSubmit (title, body)} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| {forEdit ? '編輯' : '追加'} | |||||
| </button> | |||||
| </>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -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 }) => <SectionTitle>{children}</SectionTitle>, | |||||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||||
| a: ({ href, children }) => { | |||||
| if (!(href)) | |||||
| return <>{children}</> | |||||
| if (!(preview) && ['/', '.'].some (e => href.startsWith (e))) | |||||
| return <PrefetchLink to={href}>{children}</PrefetchLink> | |||||
| const ext = /^(?:https?:)?\/\//.test (href) | |||||
| return ( | |||||
| <a href={href} | |||||
| target={ext ? '_blank' : undefined} | |||||
| rel={ext ? 'noopener noreferrer' : undefined}> | |||||
| {children} | |||||
| </a>) | |||||
| }, | |||||
| img: (({ src, alt }) => ( | |||||
| <img src={src ?? ''} | |||||
| alt={alt ?? ''} | |||||
| className="max-w-[240px] max-h-[320px]"/>)), | |||||
| } 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 ( | |||||
| <ReactMarkdown | |||||
| components={components} | |||||
| remarkPlugins={remarkPlugins}> | |||||
| {body | |||||
| || (title | |||||
| ? ('このページは存在しません。' | |||||
| +`[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`) | |||||
| : '')} | |||||
| </ReactMarkdown>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -1,10 +1,9 @@ | |||||
| import { useQueryClient } from '@tanstack/react-query' | import { useQueryClient } from '@tanstack/react-query' | ||||
| import MarkdownIt from 'markdown-it' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import MdEditor from 'react-markdown-editor-lite' | |||||
| import { useParams, useNavigate } from 'react-router-dom' | import { useParams, useNavigate } from 'react-router-dom' | ||||
| import WikiEditForm from '@/components/WikiEditForm' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| @@ -18,8 +17,6 @@ import type { FC } from 'react' | |||||
| import type { User, WikiPage } from '@/types' | import type { User, WikiPage } from '@/types' | ||||
| const mdParser = new MarkdownIt | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| @@ -37,7 +34,7 @@ export default (({ user }: Props) => { | |||||
| const [loading, setLoading] = useState (true) | const [loading, setLoading] = useState (true) | ||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const handleSubmit = async () => { | |||||
| const handleSubmit = async (title: string, body: string) => { | |||||
| const formData = new FormData () | const formData = new FormData () | ||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('body', body) | formData.append ('body', body) | ||||
| @@ -46,8 +43,6 @@ export default (({ user }: Props) => { | |||||
| { | { | ||||
| await apiPut (`/wiki/${ id }`, formData, | await apiPut (`/wiki/${ id }`, formData, | ||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | { headers: { 'Content-Type': 'multipart/form-data' } }) | ||||
| qc.setQueryData (wikiKeys.show (title, { }), | |||||
| (prev: WikiPage) => ({ ...prev, title, body })) | |||||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | qc.invalidateQueries ({ queryKey: wikiKeys.root }) | ||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ title }`) | navigate (`/wiki/${ title }`) | ||||
| @@ -77,32 +72,11 @@ export default (({ user }: Props) => { | |||||
| <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | ||||
| {loading ? 'Loading...' : ( | {loading ? 'Loading...' : ( | ||||
| <> | |||||
| {/* タイトル */} | |||||
| {/* TODO: タグ補完 */} | |||||
| <div> | |||||
| <label className="block font-semibold mb-1">タイトル</label> | |||||
| <input type="text" | |||||
| value={title} | |||||
| onChange={e => setTitle (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* 本文 */} | |||||
| <div> | |||||
| <label className="block font-semibold mb-1">本文</label> | |||||
| <MdEditor value={body} | |||||
| style={{ height: '500px' }} | |||||
| renderHTML={text => mdParser.render (text)} | |||||
| onChange={({ text }) => setBody (text)}/> | |||||
| </div> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 編輯 | |||||
| </button> | |||||
| </>)} | |||||
| <WikiEditForm | |||||
| title={title} | |||||
| body={body} | |||||
| onSubmit={handleSubmit} | |||||
| id={Number (id)}/>)} | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -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 { Helmet } from 'react-helmet-async' | ||||
| import MdEditor from 'react-markdown-editor-lite' | |||||
| import { useLocation, useNavigate } from 'react-router-dom' | import { useLocation, useNavigate } from 'react-router-dom' | ||||
| import WikiEditForm from '@/components/WikiEditForm' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiPost } from '@/lib/api' | import { apiPost } from '@/lib/api' | ||||
| import { wikiKeys } from '@/lib/queryKeys' | |||||
| import Forbidden from '@/pages/Forbidden' | import Forbidden from '@/pages/Forbidden' | ||||
| import 'react-markdown-editor-lite/lib/index.css' | import 'react-markdown-editor-lite/lib/index.css' | ||||
| import type { User, WikiPage } from '@/types' | import type { User, WikiPage } from '@/types' | ||||
| const mdParser = new MarkdownIt | |||||
| type Props = { user: User | null } | type Props = { user: User | null } | ||||
| @@ -26,13 +24,12 @@ export default ({ user }: Props) => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const qc = useQueryClient () | |||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const titleQuery = query.get ('title') ?? '' | 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 | const formData = new FormData | ||||
| formData.append ('title', title) | formData.append ('title', title) | ||||
| formData.append ('body', body) | formData.append ('body', body) | ||||
| @@ -41,6 +38,7 @@ export default ({ user }: Props) => { | |||||
| { | { | ||||
| const data = await apiPost<WikiPage> ('/wiki', formData, | const data = await apiPost<WikiPage> ('/wiki', formData, | ||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | { headers: { 'Content-Type': 'multipart/form-data' } }) | ||||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | |||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ data.title }`) | navigate (`/wiki/${ data.title }`) | ||||
| } | } | ||||
| @@ -58,30 +56,10 @@ export default ({ user }: Props) => { | |||||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | <div className="max-w-xl mx-auto p-4 space-y-4"> | ||||
| <h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1> | <h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1> | ||||
| {/* タイトル */} | |||||
| {/* TODO: タグ補完 */} | |||||
| <div> | |||||
| <label className="block font-semibold mb-1">タイトル</label> | |||||
| <input type="text" | |||||
| value={title} | |||||
| onChange={e => setTitle (e.target.value)} | |||||
| className="w-full border p-2 rounded"/> | |||||
| </div> | |||||
| {/* 本文 */} | |||||
| <div> | |||||
| <label className="block font-semibold mb-1">本文</label> | |||||
| <MdEditor value={body} | |||||
| style={{ height: '500px' }} | |||||
| renderHTML={text => mdParser.render (text)} | |||||
| onChange={({ text }) => setBody (text)}/> | |||||
| </div> | |||||
| {/* 送信 */} | |||||
| <button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 追加 | |||||
| </button> | |||||
| <WikiEditForm | |||||
| title={titleQuery} | |||||
| body="" | |||||
| onSubmit={handleSubmit}/> | |||||
| </div> | </div> | ||||
| </MainArea>) | </MainArea>) | ||||
| } | } | ||||
| @@ -156,6 +156,11 @@ export type User = { | |||||
| export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior] | export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior] | ||||
| export type WikiAsset = { | |||||
| wikiPageId: number | |||||
| no: number | |||||
| url: string } | |||||
| export type WikiPage = { | export type WikiPage = { | ||||
| id: number | id: number | ||||
| title: string | title: string | ||||