Browse Source

#171

feature/171
みてるぞ 2 weeks ago
parent
commit
de86879e79
7 changed files with 339 additions and 67 deletions
  1. +79
    -42
      backend/app/controllers/posts_controller.rb
  2. +1
    -0
      backend/app/models/tag.rb
  3. +15
    -10
      frontend/src/App.tsx
  4. +48
    -8
      frontend/src/components/PostEditForm.tsx
  5. +184
    -0
      frontend/src/components/dialogues/DialogueProvider.tsx
  6. +9
    -4
      frontend/src/lib/posts.ts
  7. +3
    -3
      frontend/src/pages/posts/PostHistoryPage.tsx

+ 79
- 42
backend/app/controllers/posts_controller.rb View File

@@ -44,7 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:materials, { tag_name: :wiki_page }])
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail


q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
end end


def random def random
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
end end


def show def show
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post


render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
@@ -173,8 +173,12 @@ class PostsController < 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?


base_version_no = parse_base_version_no
force = truthy_param?(params[:force]) force = truthy_param?(params[:force])
merge = truthy_param?(params[:merge])
return head :bad_request if force && merge

base_version_no = nil
base_version_no = parse_base_version_no unless force


title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
@@ -186,12 +190,17 @@ class PostsController < ApplicationController
conflict_json = nil conflict_json = nil


ApplicationRecord.transaction do ApplicationRecord.transaction do
post = Post.find(params[:id].to_i)
post = Post.lock.find(params[:id].to_i)


base_version = post.post_versions.find_by!(version_no: base_version_no)
base_version = nil
base_snapshot = nil
current_snapshot = nil
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)


base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(post, incoming_snapshot = post_incoming_snapshot(post,
title:, title:,
original_created_from:, original_created_from:,
@@ -199,29 +208,28 @@ class PostsController < ApplicationController
tag_names:, tag_names:,
parent_post_ids:) parent_post_ids:)


if !(force) && post.version_no != base_version_no
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:)
raise ActiveRecord::Rollback
end

PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title:, original_created_from:, original_created_before:)

normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)

tags = post.tags.nico.to_a + normalised_tags
tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
snapshot_to_apply =
if post.version_no == base_version_no || force
incoming_snapshot
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }


sync_parent_posts!(post, parent_post_ids)
if merge && conflicts.empty?
merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot)
else
conflict_json = post_conflict_json(post:,
base_version_no:,
base_snapshot:,
current_snapshot:,
incoming_snapshot:,
changes:,
conflicts:)
raise ActiveRecord::Rollback
end
end


PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
apply_post_snapshot!(post, snapshot_to_apply)
end end


return render json: conflict_json, status: :conflict if conflict_json return render json: conflict_json, status: :conflict if conflict_json
@@ -253,7 +261,7 @@ class PostsController < ApplicationController
pts = pts.where(post_id: id) if id.present? pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present? pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user, pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:materials, { tag_name: :wiki_page }])
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])


events = [] events = []
pts.each do |pt| pts.each do |pt|
@@ -446,11 +454,11 @@ class PostsController < ApplicationController
{ title: version.title, { title: version.title,
original_created_from: snapshot_time(version.original_created_from), original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before), original_created_before: snapshot_time(version.original_created_before),
tag_names: version.tags.to_s.split.sort,
tag_names: version.tags.to_s.split.filter { !(_1.start_with?('nico:')) }.sort,
parent_post_ids: snapshot_parent_post_ids_from_version(version) } parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end end


def post_snapshot_form_record post
def post_snapshot_from_record post
{ title: post.title, { title: post.title,
original_created_from: snapshot_time(post.original_created_from), original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before), original_created_before: snapshot_time(post.original_created_before),
@@ -460,7 +468,7 @@ class PostsController < ApplicationController


def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, def post_incoming_snapshot post, title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids: tag_names:, parent_post_ids:
{ title:
{ title:,
original_created_from: snapshot_time(original_created_from), original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before), original_created_before: snapshot_time(original_created_before),
tag_names: incoming_tag_names_for_snapshot(post, tag_names), tag_names: incoming_tag_names_for_snapshot(post, tag_names),
@@ -488,17 +496,16 @@ class PostsController < ApplicationController


def incoming_tag_names_for_snapshot post, raw_tag_names def incoming_tag_names_for_snapshot post, raw_tag_names
manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names)
nico_names = post.tags.nico.joins(:tag_name).pluck('tag_names.name')


existing_tags = existing_tags =
Tag Tag
.joins(:tag_name) .joins(:tag_name)
.where(tag_names: { name: manual_names + nico_names })
.where(tag_names: { name: manual_names })
.to_a .to_a


expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name)


(manual_names + nico_names + expanded_names).uniq.sort
(manual_names + expanded_names).uniq.sort
end end


def normalised_manual_tag_names_for_snapshot raw_tag_names def normalised_manual_tag_names_for_snapshot raw_tag_names
@@ -528,10 +535,7 @@ class PostsController < ApplicationController
end end


def post_conflict_json post:, base_version_no:, base_snapshot:, def post_conflict_json post:, base_version_no:, base_snapshot:,
current_snapshot:, incoming_snapshot:
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
conflicts = changes.select { |change| change[:conflict] }

current_snapshot:, incoming_snapshot:, changes:, conflicts:
{ error: 'conflict', { error: 'conflict',
message: '競合が発生しました.', message: '競合が発生しました.',
post_id: post.id, post_id: post.id,
@@ -548,9 +552,9 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル', [scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, '元コンテンツ作成日時(開始)',
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, '元コンテンツ作成日時(終了)',
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
set_snapshot_change(:tag_names, 'タグ', set_snapshot_change(:tag_names, 'タグ',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
@@ -606,4 +610,37 @@ class PostsController < ApplicationController
added_by_me:, removed_by_me: added_by_me:, removed_by_me:
(added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present?
end end

def apply_post_snapshot! post, snapshot
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)

post.update!(title: snapshot[:title],
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])

tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)

tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)

sync_parent_posts!(post, snapshot[:parent_post_ids])

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end

def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :original_created_from, :original_created_before, :tag_names, :parent_post_ids].map {
[_1, merge_scaler_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h
end

def merge_scaler_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine

raise ArgumentError, '競合してゐる項目はマージできません.'
end
end end

+ 1
- 0
backend/app/models/tag.rb View File

@@ -136,6 +136,7 @@ class Tag < ApplicationRecord
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?


Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
t.version_no = TagVersion.where(tag_id: t.id).order(version_no: :desc).first || 1
t.category = category t.category = category
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique


+ 15
- 10
frontend/src/App.tsx View File

@@ -8,6 +8,7 @@ import { BrowserRouter,


import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -138,17 +139,21 @@ export default (() => {
return ( return (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>

<BrowserRouter> <BrowserRouter>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<Toaster/>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>

<Toaster/>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
}) satisfies FC }) satisfies FC

+ 48
- 8
frontend/src/components/PostEditForm.tsx View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
@@ -41,29 +42,68 @@ export default (({ post, onSave }: Props) => {
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)


const handleSubmit = async () => {
const dialogue = useDialogue ()

const update = async (...args: Parameters<typeof updatePost>) => {
try try
{ {
const data =
await updatePost ({ id: post.id, versionNo: post.versionNo + 1,
title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore })
const data = await updatePost (...args)
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo,
title: data.title, title: data.title,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts, parentPosts: data.parentPosts,
childPosts: data.childPosts, childPosts: data.childPosts,
siblingPosts: data.siblingPosts, siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post) originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' }) toast ({ description: '更新しました.' })
} }
catch
catch (e)
{ {
toast ({ description: '更新はできなかったよ……' })
if (e.response.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
}

const action = await dialogue.choice ({
title: '競合が発生しました.',
description: (
<div>
<p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p>
</div>),
choices: [{ value: 'merge', label: '差分をマージ' },
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })

if (action === 'merge')
{
// TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true })
return
}

if (action === 'overwrite')
{
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true })
return
}
} }
} }


const handleSubmit = async e => {
e.preventDefault ()

await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
}

useEffect (() => { useEffect (() => {
setTags(tagsToStr (post.tags)) setTags(tagsToStr (post.tags))
}, [post]) }, [post])


+ 184
- 0
frontend/src/components/dialogues/DialogueProvider.tsx View File

@@ -0,0 +1,184 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'

import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle } from '@/components/ui/dialog'

import type { FC, ReactNode } from 'react'

type DialogueVariant = 'default' | 'danger'

type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }

type AlertOptions = { title: string
description?: ReactNode
okText?: string }

type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }

type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }

type DialogueRequest =
| { id: number
kind: 'confirm'
options: ConfirmOptions
resolve: (value: boolean) => void }
| { id: number
kind: 'alert'
options: AlertOptions
resolve: () => void }
| { id: number
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }

type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }

const DialogueContext = createContext<DialogueAPI | null> (null)

let nextDialogueId = 1

type Props = { children: ReactNode }


export default (({ children }: Props) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])

const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId

setQueue (q => [...q, { ...request, id } as DialogueRequest])
}, [])

const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q

if (!(active))
return rest

switch (active.kind)
{
case 'confirm':
active.resolve (Boolean (result))
break

case 'alert':
active.resolve ()
break

case 'choice':
active.resolve ((result ?? null) as string | null)
break
}

return rest
})
}, [])

const api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
}),
alert: options => new Promise<void> (resolve => {
push ({ kind: 'alert', options, resolve })
}),
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })
}) }), [push])

const active = queue[0]

return (
<DialogueContext.Provider value={api}>
{children}

<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent>
<DialogTitle>{active.options.title}</DialogTitle>

{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}

<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>

<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}

{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}

{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>

{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
</DialogueContext.Provider>)
}) satisfies FC<Props>


export const useDialogue = () => {
const dialogue = useContext (DialogueContext)

if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')

return dialogue
}

+ 9
- 4
frontend/src/lib/posts.ts View File

@@ -44,21 +44,26 @@ export const fetchPostChanges = async (


export const updatePost = async ( export const updatePost = async (
post: { id: number post: { id: number
versionNo: number
title: string | null title: string | null
tags: string tags: string
parentPostIds: string parentPostIds: string
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null }, originalCreatedBefore: string | null },
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) => ) =>
await apiPut<Post> ( await apiPut<Post> (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ version_no: post.versionNo,
title: post.title,
{ title: post.title,
tags: post.tags, tags: post.tags,
parent_post_ids: post.parentPostIds, parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom, original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore })
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })




export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {


+ 3
- 3
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -73,7 +73,6 @@ export default (() => {
try try
{ {
const id = change.postId const id = change.postId
const versionNo = change.latestVersionNo + 1
const title = change.title.current const title = change.title.current
const tags = const tags =
change.tags change.tags
@@ -88,8 +87,9 @@ export default (() => {
.join (' ') .join (' ')
const originalCreatedFrom = change.originalCreatedFrom.current const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, versionNo, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore })
await updatePost ({ id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ force: true })


qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })


Loading…
Cancel
Save