Browse Source

#171

feature/171
みてるぞ 2 weeks ago
parent
commit
5b50642756
12 changed files with 185 additions and 122 deletions
  1. +51
    -56
      backend/app/controllers/posts_controller.rb
  2. +2
    -0
      backend/app/models/post.rb
  3. +2
    -1
      backend/app/models/tag.rb
  4. +2
    -0
      backend/app/models/wiki_page.rb
  5. +26
    -11
      frontend/src/components/PostEditForm.tsx
  6. +8
    -6
      frontend/src/components/PostFormTagsArea.tsx
  7. +7
    -1
      frontend/src/components/PostOriginalCreatedTimeField.tsx
  8. +3
    -3
      frontend/src/components/TopNav.tsx
  9. +5
    -4
      frontend/src/components/common/DateTimeField.tsx
  10. +13
    -11
      frontend/src/components/ui/dialog.tsx
  11. +50
    -28
      frontend/src/index.css
  12. +16
    -1
      frontend/tailwind.config.js

+ 51
- 56
backend/app/controllers/posts_controller.rb View File

@@ -173,8 +173,8 @@ 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?


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


base_version_no = nil base_version_no = nil
@@ -201,15 +201,14 @@ class PostsController < ApplicationController
base_snapshot = post_snapshot_from_version(base_version) base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post) current_snapshot = post_snapshot_from_record(post)
end end
incoming_snapshot = post_incoming_snapshot(post,
title:,
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
parent_post_ids:) parent_post_ids:)


snapshot_to_apply = snapshot_to_apply =
if post.version_no == base_version_no || force
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
incoming_snapshot incoming_snapshot
else else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
@@ -448,30 +447,36 @@ class PostsController < ApplicationController
version_no version_no
end end


def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value)

def post_snapshot_from_version version def post_snapshot_from_version version
{ 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.filter { !(_1.start_with?('nico:')) }.sort,
tag_names: editable_tag_names_from_version(version),
parent_post_ids: snapshot_parent_post_ids_from_version(version) } parent_post_ids: snapshot_parent_post_ids_from_version(version) }
end end


def editable_tag_names_from_version version
version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort
end

def post_snapshot_from_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),
tag_names: post.tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name'),
tag_names: editable_tag_names_from_post(post),
parent_post_ids: post.parent_posts.order(:id).pluck(:id) } parent_post_ids: post.parent_posts.order(:id).pluck(:id) }
end end


def post_incoming_snapshot post, title:, original_created_from:, original_created_before:,
tag_names:, parent_post_ids:
def editable_tag_names_from_post post
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end

def post_incoming_snapshot title:, original_created_from:, original_created_before:,
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(tag_names),
parent_post_ids: parent_post_ids.sort } parent_post_ids: parent_post_ids.sort }
end end


@@ -494,44 +499,10 @@ class PostsController < ApplicationController
value.to_s value.to_s
end end


def incoming_tag_names_for_snapshot post, raw_tag_names
manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names)

existing_tags =
Tag
.joins(:tag_name)
.where(tag_names: { name: manual_names })
.to_a
def incoming_tag_names_for_snapshot raw_tag_names
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)


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

(manual_names + expanded_names).uniq.sort
end

def normalised_manual_tag_names_for_snapshot raw_tag_names
if raw_tag_names.any? { |name| name.downcase.start_with?('nico:') }
raise Tag::NicoTagNormalisationError
end

pairs = raw_tag_names.map do |raw_name|
prefix, category =
Tag::CATEGORY_PREFIXES.find { |p, _| raw_name.downcase.start_with?(p) } || ['', nil]

name = TagName.canonicalise(raw_name.sub(/\A#{ Regexp.escape(prefix) }/i, '')).first

[name, category]
end

names = pairs.map(&:first)

has_deerjikist = pairs.any? do |name, category|
category == :deerjikist ||
Tag.joins(:tag_name).where(category: :deerjikist, tag_names: { name: }).exists?
end

names << Tag.no_deerjikist.name unless has_deerjikist

names.uniq.sort
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
end end


def post_conflict_json post:, base_version_no:, base_snapshot:, def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -618,29 +589,53 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) 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)
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)

readonly_tags = post.tags.nico.to_a


tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)


sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids]) sync_parent_posts!(post, snapshot[:parent_post_ids])


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


def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot 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],
[:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1], current_snapshot[_1],
incoming_snapshot[_1])] incoming_snapshot[_1])]
}.to_h
}.to_h.merge([:tag_names, :parent_post_ids].map {
[_1, merge_set_snapshot_value(base_snapshot[_1],
current_snapshot[_1],
incoming_snapshot[_1])]
}.to_h)
end end


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


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

def merge_set_snapshot_value base, current, mine
base = base.to_a
current = current.to_a
mine = mine.to_a

added_by_current = current - base
removed_by_current = base - current
added_by_me = mine - base
removed_by_me = base - mine

merged = base + added_by_current + added_by_me
merged -= removed_by_current
merged -= removed_by_me

merged.uniq.sort
end
end end

+ 2
- 0
backend/app/models/post.rb View File

@@ -28,6 +28,8 @@ class Post < ApplicationRecord


has_one_attached :thumbnail has_one_attached :thumbnail


attribute :version_no, :integer, default: 1

before_validation :normalise_url before_validation :normalise_url


validates :url, presence: true, uniqueness: true validates :url, presence: true, uniqueness: true


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

@@ -40,6 +40,8 @@ class Tag < ApplicationRecord
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name


attribute :version_no, :integer, default: 1

delegate :name, to: :tag_name, allow_nil: true delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true validates :tag_name, presence: true


@@ -136,7 +138,6 @@ 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


+ 2
- 0
backend/app/models/wiki_page.rb View File

@@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord


has_many :wiki_versions has_many :wiki_versions


attribute :version_no, :integer, default: 1

belongs_to :tag_name belongs_to :tag_name
validates :tag_name, presence: true validates :tag_name, presence: true
validates :body, presence: true validates :body, presence: true


+ 26
- 11
frontend/src/components/PostEditForm.tsx View File

@@ -8,7 +8,7 @@ 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'


import type { FC } from 'react'
import type { FC, FormEvent } from 'react'


import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'


@@ -33,6 +33,7 @@ type Props = { post: Post




export default (({ post, onSave }: Props) => { export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -61,7 +62,9 @@ export default (({ post, onSave }: Props) => {
} }
catch (e) catch (e)
{ {
if (e.response.status !== 409)
const response = (e as any)?.response

if (response?.status !== 409)
{ {
toast ({ description: '更新はできなかったよ……' }) toast ({ description: '更新はできなかったよ……' })
return return
@@ -74,7 +77,7 @@ export default (({ post, onSave }: Props) => {
<p>ほかの耕作員が先に更新してゐます.</p> <p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p> <p>現在の変更をどう扱ひますか?</p>
</div>), </div>),
choices: [{ value: 'merge', label: '差分をマージ' },
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) { value: 'overwrite', label: '強制上書き', variant: 'danger' }] })


if (action === 'merge') if (action === 'merge')
@@ -96,12 +99,14 @@ export default (({ post, onSave }: Props) => {
} }
} }


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


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


useEffect (() => { useEffect (() => {
@@ -113,10 +118,12 @@ export default (({ post, onSave }: Props) => {
{/* タイトル */} {/* タイトル */}
<div> <div>
<Label>タイトル</Label> <Label>タイトル</Label>
<input type="text"
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
<input
type="text"
disabled={disabled}
className="w-full border rounded p-2"
value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>
</div> </div>


{/* 親投稿 */} {/* 親投稿 */}
@@ -124,24 +131,32 @@ export default (({ post, onSave }: Props) => {
<Label>親投稿</Label> <Label>親投稿</Label>
<input <input
type="text" type="text"
disabled={disabled}
value={parentPostIds} value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)} onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/> className="w-full border p-2 rounded"/>
</div> </div>


{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>


{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore}/> setOriginalCreatedBefore={setOriginalCreatedBefore}/>


{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
<Button
type="submit"
disabled={disabled}
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
更新 更新
</Button> </Button>
</div>) </div>)


+ 8
- 6
frontend/src/components/PostFormTagsArea.tsx View File

@@ -7,7 +7,7 @@ import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'


import type { FC, SyntheticEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'


import type { Tag } from '@/types' import type { Tag } from '@/types'


@@ -31,12 +31,13 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
`${ value.slice (0, start) }${ text }${ value.slice (end) }` `${ value.slice (0, start) }${ text }${ value.slice (end) }`




type Props = {
tags: string
setTags: (tags: string) => void }
type Props =
& { tags: string
setTags: (tags: string) => void }
& ComponentPropsWithoutRef<'textarea'>




export default (({ tags, setTags }: Props) => {
export default (({ tags, setTags, ...rest }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)


const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -87,7 +88,8 @@ export default (({ tags, setTags }: Props) => {
onBlur={() => { onBlur={() => {
setFocused (false) setFocused (false)
setSuggestionsVsbl (false) setSuggestionsVsbl (false)
}}/>
}}
{...rest}/>
{focused && ( {focused && (
<TagSearchBox <TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0 suggestions={suggestionsVsbl && suggestions.length > 0


+ 7
- 1
frontend/src/components/PostOriginalCreatedTimeField.tsx View File

@@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC } from 'react'


type Props = { type Props = {
disabled?: boolean
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void } setOriginalCreatedBefore: (x: string | null) => void }




export default (({ originalCreatedFrom,
export default (({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore }: Props) => ( setOriginalCreatedBefore }: Props) => (
@@ -21,6 +23,7 @@ export default (({ originalCreatedFrom,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined} value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom} onChange={setOriginalCreatedFrom}
onBlur={ev => { onBlur={ev => {
@@ -40,6 +43,7 @@ export default (({ originalCreatedFrom,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
@@ -51,6 +55,7 @@ export default (({ originalCreatedFrom,
<div className="w-80"> <div className="w-80">
<DateTimeField <DateTimeField
className="mr-2" className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined} value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> onChange={setOriginalCreatedBefore}/>
より前 より前
@@ -58,6 +63,7 @@ export default (({ originalCreatedFrom,
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedBefore (null) setOriginalCreatedBefore (null)
}}> }}>


+ 3
- 3
frontend/src/components/TopNav.tsx View File

@@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' }, { name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' }, { name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' }, { name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '履歴', to: '/tags/changes' },
{ name: '全体履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg }, { component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`, { name: `広場 (${ postCount || 0 })`,
@@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/materials' }, { name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false }, { name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes', visible: false },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' }, { name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' },


+ 5
- 4
frontend/src/components/common/DateTimeField.tsx View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'


import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'


import type { FC, FocusEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'




const pad = (n: number): string => n.toString ().padStart (2, '0') const pad = (n: number): string => n.toString ().padStart (2, '0')
@@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => {
} }




type Props = {
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string value?: string
onChange?: (isoUTC: string | null) => void onChange?: (isoUTC: string | null) => void
className?: string className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }




export default (({ value, onChange, className, onBlur }: Props) => {
export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
const [local, setLocal] = useState ('') const [local, setLocal] = useState ('')


useEffect (() => { useEffect (() => {
@@ -42,5 +42,6 @@ export default (({ value, onChange, className, onBlur }: Props) => {
setLocal (v) setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null) onChange?.(v ? (new Date (v)).toISOString () : null)
}} }}
onBlur={onBlur}/>)
onBlur={onBlur}
{...rest}/>)
}) satisfies FC<Props> }) satisfies FC<Props>

+ 13
- 11
frontend/src/components/ui/dialog.tsx View File

@@ -37,25 +37,27 @@ const DialogContent = React.forwardRef<
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg',
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]', 'translate-x-[-50%] translate-y-[-50%]',
'gap-4 border bg-gray-300/80 dark:bg-gray-700/80',
'p-6 shadow-lg duration-200',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out', 'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2',
'data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2',
'data-[state=open]:slide-in-from-top-[48%] rounded-lg',
className)} className)}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 bg-red-500 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
<DialogPrimitive.Close
className={cn (
'absolute right-4 top-4 rounded-full p-1',
'text-muted-foreground opacity-70 transition-opacity',
'hover:bg-accent hover:text-accent-foreground hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}>
<X className="h-4 w-4"/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>


+ 50
- 28
frontend/src/index.css View File

@@ -6,6 +6,56 @@


@layer base @layer base
{ {
:root
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;

--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;

--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;

--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;

--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;

--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;

--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
}

.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;

--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;

--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;

--destructive: 0 62.8% 45%;
--destructive-foreground: 210 40% 98%;

--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;

--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;

--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}

body body
{ {
@apply overflow-x-clip; @apply overflow-x-clip;
@@ -54,34 +104,6 @@ body
min-height: 100dvh; min-height: 100dvh;
} }


h1
{
font-size: 3.2em;
line-height: 1.1;
}

button
{
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover
{
border-color: #646cff;
}
button:focus,
button:focus-visible
{
outline: 4px auto -webkit-focus-ring-color;
}

@media (prefers-color-scheme: light) @media (prefers-color-scheme: light)
{ {
:root :root


+ 16
- 1
frontend/tailwind.config.js View File

@@ -19,7 +19,22 @@ export default {
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
colors: { colors: {
red: { 925: '#5f1414', red: { 925: '#5f1414',
975: '#230505' } },
975: '#230505' },
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: { DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' },
secondary: { DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))' },
destructive: { DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))' },
muted: { DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' },
accent: { DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' } },
keyframes: { keyframes: {
'rainbow-scroll': { 'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' }, '0%': { backgroundPosition: '0% 50%' },


Loading…
Cancel
Save