コミットを比較

..

8 コミット

作成者 SHA1 メッセージ 日付
みてるぞ f662dc9dc0 #399 2026-07-18 20:22:53 +09:00
みてるぞ 206c6bc0a0 #399 2026-07-18 20:10:15 +09:00
みてるぞ 8f66ee8059 #399 2026-07-18 20:03:26 +09:00
みてるぞ 83e3db3314 #399 2026-07-18 20:00:40 +09:00
みてるぞ eae6c30064 #399 2026-07-18 19:26:27 +09:00
みてるぞ 240e078f0b #399 2026-07-18 18:33:11 +09:00
みてるぞ 74b1ada0dd #399 2026-07-18 17:44:56 +09:00
みてるぞ 23d8adf65d #399 2026-07-18 17:35:28 +09:00
16個のファイルの変更231行の追加171行の削除
+8 -3
ファイルの表示
@@ -216,7 +216,8 @@ class PostsController < ApplicationController
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail]).run
thumbnail: params[:thumbnail],
host: request.base_url).run
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
@@ -273,7 +274,11 @@ class PostsController < ApplicationController
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(actor: current_user, posts:, thumbnails:).run
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
@@ -627,7 +632,7 @@ class PostsController < ApplicationController
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post)
PostCompactRepr.base(post, host: request.base_url)
end
def dry_run_json preflight
+2 -2
ファイルの表示
@@ -4,11 +4,11 @@
module PostCompactRepr
module_function
def base post
def base post, host: nil
return nil if post.nil?
PostRepr
.common(post)
.common(post, host:)
.slice(
'id',
'title',
+56 -20
ファイルの表示
@@ -17,8 +17,13 @@ module PostRepr
module_function
def base post, current_user = nil
json = common(post)
def base post, current_user = nil, host: nil
json =
if host.present?
common(post, host:)
else
common(post)
end
json['tags'] = tag_json(post)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
@@ -26,31 +31,59 @@ module PostRepr
end
def detail post, current_user = nil, parent_posts: [], child_posts: [],
sibling_posts: { }, related: []
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
sibling_posts: { }, related: [], host: nil
if host.present?
base(post, current_user, host:).merge(
'parent_posts' => cards(parent_posts, host:),
'child_posts' => cards(child_posts, host:),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts, host:)
},
'related' => cards(related, host:))
else
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
def card post, host: nil
if host.present?
common(post, host:).merge('parent_posts' => [], 'child_posts' => [])
else
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
end
def cards posts
posts.map { |post| card(post) }
def cards posts, host: nil
if host.present?
posts.map { |post| card(post, host:) }
else
posts.map { |post| card(post) }
end
end
def many posts, current_user = nil
posts.map { |p| base(p, current_user) }
def many posts, current_user = nil, host: nil
if host.present?
posts.map { |p| base(p, current_user, host:) }
else
posts.map { |p| base(p, current_user) }
end
end
def common post
def common post, host: nil
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
.merge(
'thumbnail' =>
if host.present?
thumbnail_url(post, host:)
else
thumbnail_url(post)
end)
end
def tag_json post
@@ -65,10 +98,13 @@ module PostRepr
}
end
def thumbnail_url post
def thumbnail_url post, host: nil
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
options = { only_path: false }
options[:host] = host if host.present?
Rails.application.routes.url_helpers.rails_storage_proxy_url(post.thumbnail, **options)
rescue ActionController::UrlGenerationError, ArgumentError, URI::InvalidURIError => e
Rails.logger.warn(
"PostRepr.thumbnail_url failed post_id=#{post.id} " \
+5 -3
ファイルの表示
@@ -1,8 +1,9 @@
class PostBulkCreator
def initialize actor:, posts:, thumbnails:
def initialize actor:, posts:, thumbnails:, host: nil
@actor_id = actor.id
@posts = posts
@thumbnails = thumbnails
@host = host
end
def run
@@ -64,7 +65,8 @@ class PostBulkCreator
preflight =
PostCreatePreflight.new(
attributes: attributes,
thumbnail: thumbnail_for(index, attributes)).run
thumbnail: thumbnail_for(index, attributes),
host: @host).run
if preflight[:existing_post_id].present?
return {
status: 'skipped',
@@ -195,6 +197,6 @@ class PostBulkCreator
end
def compact_existing_post post
PostCompactRepr.base(post)
PostCompactRepr.base(post, host: @host)
end
end
+3 -2
ファイルの表示
@@ -9,9 +9,10 @@ class PostCreatePreflight
end
end
def initialize attributes:, thumbnail: nil
def initialize attributes:, thumbnail: nil, host: nil
@attributes = attributes.symbolize_keys
@thumbnail = thumbnail
@host = host
end
def run
@@ -124,7 +125,7 @@ class PostCreatePreflight
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post)
PostCompactRepr.base(post, host: @host)
end
def final_field_warnings field_warnings
+42 -37
ファイルの表示
@@ -1,8 +1,9 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
import { createBrowserRouter,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
@@ -154,7 +155,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const App: FC = () => {
const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
@@ -253,42 +254,46 @@ const App: FC = () => {
}
return (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<BrowserRouter>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</MotionConfig>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>)
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const App: FC = () => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
</>)
export default App
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
}, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
+1 -1
ファイルの表示
@@ -10,8 +10,8 @@ import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft
+6 -3
ファイルの表示
@@ -4,13 +4,16 @@ import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { hasVideoTag } from '@/lib/postImportSession'
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
import {
canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
type Props = {
row: PostImportRow
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
'ready'
-5
ファイルの表示
@@ -226,11 +226,6 @@ export const buildNextEditedRow = (
? 'manual'
: (editingRow.provenance[field] ?? 'automatic')
})
if (nextProvenance.duration === 'manual')
{
nextAttributes.videoMs = ''
nextProvenance.videoMs = 'manual'
}
nextAttributes.tags = draft.tags
if (isManualChange (editingRow.attributes.tags, draft.tags))
{
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+2 -7
ファイルの表示
@@ -25,7 +25,6 @@ import {
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
navigate (path)
}, [navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
+102 -55
ファイルの表示
@@ -7,7 +7,7 @@ import {
useRef,
useState,
} from 'react'
import { useBlocker, useLocation } from 'react-router-dom'
import { useBlocker } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue'
@@ -22,12 +22,11 @@ type UseUnsavedChangesGuardOptions = {
onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean>
allowNextNavigation: () => void }
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource,
) => () => void
allowNextNavigation: () => void }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -35,67 +34,119 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const location = useLocation ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const bypassNextNavigationRef = useRef (false)
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
source: UnsavedChangesSource,
): (() => void) => {
const sourceId = Symbol ('unsaved-changes-source')
sourcesRef.current.set (sourceId, source)
setRevision (current => current + 1)
return () => {
if (!(sourcesRef.current.delete (sourceId)))
return
setRevision (current => current + 1)
}
}, [])
const allowNextNavigation = useCallback (() => {
bypassNextNavigationRef.current = true
const token = Symbol ('allowed-navigation')
bypassNextNavigationRef.current = token
queueMicrotask (() => {
if (bypassNextNavigationRef.current === token)
bypassNextNavigationRef.current = null
})
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
const sources = useMemo (
() => [...sourcesRef.current.values ()],
[revision],
)
const dirtySources = useMemo (
() => sources.filter (source => source.dirty),
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null)
{
bypassNextNavigationRef.current = null
return false
}
return hasUnsavedChanges
}, [hasUnsavedChanges])
const blocker = useBlocker (shouldBlock)
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges))
return true
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
if (dialogueOpenRef.current)
return false
bypassNextNavigationRef.current = true
await source.discard ()
return true
}, [dialogue, source])
dialogueOpenRef.current = true
const blocker = useBlocker (() =>
source?.dirty === true && !(bypassNextNavigationRef.current))
try
{
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
try
{
for (const source of dirtySources)
await source.discard ()
}
catch
{
return false
}
return true
}
finally
{
dialogueOpenRef.current = false
}
}, [dialogue, dirtySources, hasUnsavedChanges])
useEffect (() => {
if (blocker.state !== 'blocked')
return
let cancelled = false
let active = true
void (async () => {
const confirmed = await confirmDiscardNavigation ()
if (cancelled)
return
const confirmed = await discardDirtyChanges ()
if (!(active))
return
if (confirmed)
blocker.proceed ()
else
blocker.reset ()
{
blocker.proceed ()
return
}
blocker.reset ()
}) ()
return () => {
cancelled = true
active = false
}
}, [blocker, confirmDiscardNavigation])
}, [blocker, discardDirtyChanges])
useEffect (() => {
bypassNextNavigationRef.current = false
}, [location.hash, location.pathname, location.search])
useEffect (() => {
if (!(source?.dirty))
if (!(hasUnsavedChanges))
return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -107,23 +158,21 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload)
}
}, [source?.dirty])
}, [hasUnsavedChanges])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
hasUnsavedChanges,
registerUnsavedChangesSource,
confirmDiscardNavigation,
allowNextNavigation,
}), [
allowNextNavigation,
confirmDiscardNavigation,
hasUnsavedChanges,
registerUnsavedChangesSource,
source?.dirty,
])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
{children}
</UnsavedChangesGuardContext.Provider>)
}
@@ -136,18 +185,16 @@ export const useUnsavedChangesGuard = (
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
const { registerUnsavedChangesSource } = context
useEffect (() => {
if (options == null)
return
context.registerUnsavedChangesSource ({
dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) })
return () => {
context.registerUnsavedChangesSource (null)
}
}, [context, options?.dirty, options?.onDiscard])
return registerUnsavedChangesSource ({
dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) })
}, [options?.dirty, options?.onDiscard, registerUnsavedChangesSource])
return context
}
-8
ファイルの表示
@@ -108,7 +108,6 @@ const emptyAttributes = () => ({
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
videoMs: '',
tags: '',
parentPostIds: '' })
@@ -120,7 +119,6 @@ const emptyProvenance = () => ({
originalCreatedFrom: 'automatic',
originalCreatedBefore: 'automatic',
duration: 'automatic',
videoMs: 'automatic',
tags: 'automatic',
parentPostIds: 'automatic' }) as const
@@ -294,7 +292,6 @@ const buildDryRunFormData = (row: PostImportRow): FormData => {
formData.append (
'original_created_before',
String (row.attributes.originalCreatedBefore ?? ''))
formData.append ('video_ms', String (row.attributes.videoMs ?? ''))
formData.append ('duration', String (row.attributes.duration ?? ''))
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
formData.append ('thumbnail', row.thumbnailFile)
@@ -321,7 +318,6 @@ const mergeDryRunRow = (
originalCreatedFrom: result.originalCreatedFrom ?? '',
originalCreatedBefore: result.originalCreatedBefore ?? '',
duration: result.duration ?? '',
videoMs: result.videoMs ?? '',
tags: result.tags ?? '',
parentPostIds: String (
result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
@@ -355,7 +351,6 @@ const buildPreviewResetSnapshot = (
originalCreatedFrom: preview.originalCreatedFrom ?? '',
originalCreatedBefore: preview.originalCreatedBefore ?? '',
duration: preview.duration ?? '',
videoMs: preview.videoMs ?? '',
tags: preview.tags ?? '',
parentPostIds: String (preview.parentPostIds ?? '') },
displayTags: cloneDisplayTags (preview.displayTags),
@@ -489,8 +484,6 @@ const mergePreviewRow = (
nextRow.tagSources.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.videoMs !== 'manual')
nextRow.attributes.videoMs = preview.videoMs ?? ''
if (currentRow.provenance.duration !== 'manual')
nextRow.attributes.duration = preview.duration ?? ''
@@ -526,7 +519,6 @@ const buildBulkFormData = (rows: PostImportRow[]): FormData => {
parent_post_ids: row.attributes.parentPostIds ?? '',
original_created_from: row.attributes.originalCreatedFrom ?? '',
original_created_before: row.attributes.originalCreatedBefore ?? '',
video_ms: row.attributes.videoMs ?? '',
duration: row.attributes.duration ?? '' }))))
rows.forEach ((row, index) => {
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
+3 -7
ファイルの表示
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => {
registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
return () => {
registerUnsavedChangesSource (null)
}
return registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges })
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({