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