create for series
This commit is contained in:
parent
1082f33a77
commit
53991c7791
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from './ui'
|
||||
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
||||
import { useArticlePublishing } from '@/hooks/useArticlePublishing'
|
||||
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||
@ -14,10 +15,10 @@ interface ArticleEditorProps {
|
||||
|
||||
function SuccessMessage(): React.ReactElement {
|
||||
return (
|
||||
<div className="border rounded-lg p-6 bg-green-50 border-green-200">
|
||||
<Card variant="default" className="bg-green-50 border-green-200">
|
||||
<h3 className="text-lg font-semibold text-green-800 mb-2">Article Published!</h3>
|
||||
<p className="text-green-700">Your article has been successfully published.</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Card, ErrorState } from './ui'
|
||||
import type { ArticleDraft } from '@/lib/articlePublisher'
|
||||
import { ArticleFormButtons } from './ArticleFormButtons'
|
||||
import type { RelayPublishStatus } from '@/lib/publishResult'
|
||||
@ -22,11 +23,7 @@ function ErrorAlert({ error }: { error: string | null }): React.ReactElement | n
|
||||
if (!error) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)
|
||||
return <ErrorState message={error} className="bg-red-50 border-red-200 text-red-800" />
|
||||
}
|
||||
|
||||
export function ArticleEditorForm({
|
||||
@ -41,19 +38,21 @@ export function ArticleEditorForm({
|
||||
onSelectSeries,
|
||||
}: ArticleEditorFormProps): React.ReactElement {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="border rounded-lg p-6 bg-white space-y-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
||||
<div className="space-y-4">
|
||||
<ArticleFieldsLeft
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
/>
|
||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
<ErrorAlert error={error} />
|
||||
<ArticleFormButtons loading={loading} relayStatuses={relayStatuses} {...(onCancel ? { onCancel } : {})} />
|
||||
</form>
|
||||
<Card variant="default" className="bg-white">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<h2 className="text-2xl font-bold mb-4">{t('article.editor.title')}</h2>
|
||||
<div className="space-y-4">
|
||||
<ArticleFieldsLeft
|
||||
draft={draft}
|
||||
onDraftChange={onDraftChange}
|
||||
{...(seriesOptions ? { seriesOptions } : {})}
|
||||
{...(onSelectSeries ? { onSelectSeries } : {})}
|
||||
/>
|
||||
<ArticleFieldsRight draft={draft} onDraftChange={onDraftChange} />
|
||||
</div>
|
||||
<ErrorAlert error={error} />
|
||||
<ArticleFormButtons loading={loading} relayStatuses={relayStatuses} {...(onCancel ? { onCancel } : {})} />
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import type { Article } from '@/types/nostr'
|
||||
import { Button, Card, Input } from './ui'
|
||||
import { Button, Card } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { AuthorFilter } from './AuthorFilter'
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Button } from './ui'
|
||||
import { AuthorAvatar } from './AuthorFilterDropdown'
|
||||
|
||||
export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }): React.ReactElement {
|
||||
@ -70,12 +71,13 @@ export function AuthorFilterButton({
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
id="author-filter"
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
|
||||
className="w-full px-3 py-2 border-neon-cyan/30 bg-cyber-dark text-cyber-accent text-left justify-start hover:border-neon-cyan/50"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
@ -86,7 +88,7 @@ export function AuthorFilterButton({
|
||||
getMnemonicIcons={getMnemonicIcons}
|
||||
/>
|
||||
<DropdownArrowIcon isOpen={isOpen} />
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Image from 'next/image'
|
||||
import { Card } from './ui'
|
||||
import { Button, Card } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export function AuthorAvatar({ picture, displayName }: { picture?: string; displayName: string }): React.ReactElement {
|
||||
@ -35,10 +35,11 @@ export function AuthorOption({
|
||||
onSelect: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onSelect}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
|
||||
className={`w-full px-3 py-2 text-left justify-start hover:bg-cyber-light ${
|
||||
isSelected ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
|
||||
}`}
|
||||
role="option"
|
||||
@ -53,7 +54,7 @@ export function AuthorOption({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -67,20 +68,21 @@ export function AllAuthorsOption({
|
||||
setIsOpen: (open: boolean) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onChange(null)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
|
||||
className={`w-full px-3 py-2 text-left justify-start hover:bg-cyber-light ${
|
||||
value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === null}
|
||||
>
|
||||
<span className="flex-1 text-cyber-accent">{t('filters.author')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface ClearButtonProps {
|
||||
@ -6,14 +7,12 @@ interface ClearButtonProps {
|
||||
|
||||
export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="text-neon-cyan/70 hover:text-neon-cyan transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
|
||||
aria-label={t('search.clear')}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className="text-neon-cyan/70 hover:text-neon-cyan p-0 h-auto"
|
||||
aria-label={t('search.clear')}
|
||||
>
|
||||
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
@ -22,6 +21,6 @@ export function ClearButton({ onClick }: ClearButtonProps): React.ReactElement {
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui'
|
||||
import { setLocale, getLocale, type Locale } from '@/lib/i18n'
|
||||
import { localeStorage } from '@/lib/localeStorage'
|
||||
|
||||
@ -12,16 +13,15 @@ interface LocaleButtonProps {
|
||||
function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps): React.ReactElement {
|
||||
const isActive = currentLocale === locale
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant={isActive ? 'primary' : 'ghost'}
|
||||
size="small"
|
||||
onClick={() => onClick(locale)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
isActive
|
||||
? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
|
||||
: 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
|
||||
}`}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,8 @@ export function NotificationActions({ timestamp, onDelete }: NotificationActions
|
||||
<div className="flex items-start gap-2 ml-4">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{formatTime(timestamp)}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from './ui'
|
||||
import { useNotifications } from '@/hooks/useNotifications'
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
@ -13,9 +14,11 @@ export function NotificationBadge({ userPubkey, onClick }: NotificationBadgeProp
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...(onClick !== undefined ? { onClick } : {})}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label={`${unreadCount} unread notification${unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<svg
|
||||
@ -37,6 +40,6 @@ export function NotificationBadge({ userPubkey, onClick }: NotificationBadgeProp
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from './ui'
|
||||
|
||||
interface NotificationBadgeButtonProps {
|
||||
unreadCount: number
|
||||
@ -6,9 +7,11 @@ interface NotificationBadgeButtonProps {
|
||||
|
||||
export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBadgeButtonProps): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label={`${unreadCount} unread notification${unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<svg
|
||||
@ -33,6 +36,6 @@ export function NotificationBadgeButton({ unreadCount, onClick }: NotificationBa
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Card } from './ui'
|
||||
import type { Notification } from '@/lib/notificationService'
|
||||
import { NotificationItem } from './NotificationItem'
|
||||
import { NotificationPanelHeader } from './NotificationPanelHeader'
|
||||
@ -50,7 +51,7 @@ export function NotificationPanel({
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 bg-black bg-opacity-50" onClick={onClose} />
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-[600px] flex flex-col">
|
||||
<Card variant="default" className="absolute right-0 mt-2 w-96 bg-white shadow-xl border-gray-200 z-50 max-h-[600px] flex flex-col">
|
||||
<NotificationPanelHeader
|
||||
unreadCount={unreadCount}
|
||||
onMarkAllAsRead={onMarkAllAsRead}
|
||||
@ -63,7 +64,7 @@ export function NotificationPanel({
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from './ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface NotificationPanelHeaderProps {
|
||||
@ -6,6 +7,45 @@ interface NotificationPanelHeaderProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function MarkAllAsReadButton({ unreadCount, onMarkAllAsRead }: { unreadCount: number; onMarkAllAsRead: () => void }): React.ReactElement | null {
|
||||
if (unreadCount === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={onMarkAllAsRead}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{t('notification.markAllAsRead')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseButton({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-0 min-w-0"
|
||||
aria-label={t('notification.close')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationPanelHeader({
|
||||
unreadCount,
|
||||
onMarkAllAsRead,
|
||||
@ -15,28 +55,8 @@ export function NotificationPanelHeader({
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('notification.title')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={onMarkAllAsRead}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
{t('notification.markAllAsRead')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
aria-label={t('notification.close')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<MarkAllAsReadButton unreadCount={unreadCount} onMarkAllAsRead={onMarkAllAsRead} />
|
||||
<CloseButton onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import { Button } from './ui'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export function BackButton(): React.ReactElement {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
void router.push('/')
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 text-sm font-medium mb-4"
|
||||
className="text-blue-600 hover:text-blue-700 mb-4"
|
||||
>
|
||||
← Back to Articles
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -112,7 +112,7 @@ function SeriesCategoryField(params: {
|
||||
id="series-category"
|
||||
value={params.draft.category}
|
||||
onChange={(e) => params.setDraft({ ...params.draft, category: e.target.value as SeriesDraft['category'] })}
|
||||
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-accent/30 rounded text-cyber-light focus:border-neon-cyan focus:outline-none"
|
||||
className="w-full bg-cyber-darker border-cyber-accent/30 text-cyber-light"
|
||||
required
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@ -100,10 +100,10 @@ function KeyManagementNoAccountBanner(params: {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-400/50 rounded-lg p-4 mb-6">
|
||||
<Card variant="default" className="bg-yellow-900/20 border-yellow-400/50 mb-6">
|
||||
<p className="text-yellow-400 font-semibold mb-2">{t('settings.keyManagement.noAccount.title')}</p>
|
||||
<p className="text-yellow-300/90 text-sm">{t('settings.keyManagement.noAccount.description')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -116,12 +116,13 @@ function KeyManagementImportButton(params: {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={params.onClick}
|
||||
className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
||||
className="w-full py-3 px-6"
|
||||
>
|
||||
{params.accountExists ? t('settings.keyManagement.import.button.replace') : t('settings.keyManagement.import.button.new')}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../ui'
|
||||
import type { MediaRef, Page } from '@/types/nostr'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { createPagesHandlers, PagesManager } from './PagesManager'
|
||||
@ -104,8 +105,10 @@ function MarkdownPreview(params: { value: string }): React.ReactElement {
|
||||
|
||||
function ToolbarUploadButton(params: { onFileSelected: (file: File) => void }): React.ReactElement {
|
||||
return (
|
||||
<label className="px-3 py-1 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700">
|
||||
{t('markdown.upload.media')}
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center justify-center px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
{t('markdown.upload.media')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
@ -127,20 +130,24 @@ function ToolbarAddPageButtons(params: { onAddPage: ((type: 'markdown' | 'image'
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||
variant="success"
|
||||
size="small"
|
||||
onClick={() => params.onAddPage?.('markdown')}
|
||||
className="px-3 py-1 text-sm rounded bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
{t('page.add.markdown')}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => params.onAddPage?.('image')}
|
||||
className="px-3 py-1 text-sm rounded bg-purple-600 text-white hover:bg-purple-700"
|
||||
>
|
||||
{t('page.add.image')}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button, Textarea } from '../ui'
|
||||
import { t } from '@/lib/i18n'
|
||||
import type { Page } from '@/types/nostr'
|
||||
|
||||
@ -60,13 +61,15 @@ function PageEditorHeader(params: { page: Page; onTypeChange: (type: 'markdown'
|
||||
<option value="markdown">{t('page.type.markdown')}</option>
|
||||
<option value="image">{t('page.type.image')}</option>
|
||||
</select>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
variant="danger"
|
||||
size="small"
|
||||
onClick={params.onRemove}
|
||||
className="px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{t('page.remove')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -75,11 +78,12 @@ function PageEditorHeader(params: { page: Page; onTypeChange: (type: 'markdown'
|
||||
function PageEditorBody(params: { page: Page; onContentChange: (content: string) => void; onImageUpload: (file: File) => Promise<void> }): React.ReactElement {
|
||||
if (params.page.type === 'markdown') {
|
||||
return (
|
||||
<textarea
|
||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||
<Textarea
|
||||
value={params.page.content}
|
||||
onChange={(e) => params.onContentChange(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => params.onContentChange(e.target.value)}
|
||||
placeholder={t('page.markdown.placeholder')}
|
||||
className="w-full border rounded p-2 h-48 font-mono text-sm"
|
||||
rows={12}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -91,13 +95,15 @@ function PageEditorImageBody(params: { page: Page; onContentChange: (content: st
|
||||
return (
|
||||
<div className="relative">
|
||||
<img src={params.page.content} alt={t('page.image.alt', { number: params.page.number })} className="max-w-full h-auto rounded" />
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
variant="danger"
|
||||
size="small"
|
||||
onClick={() => params.onContentChange('')}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{t('page.image.remove')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -106,8 +112,10 @@ function PageEditorImageBody(params: { page: Page; onContentChange: (content: st
|
||||
|
||||
function PageImageUploadButton(params: { onFileSelected: (file: File) => Promise<void> }): React.ReactElement {
|
||||
return (
|
||||
<label className="block px-3 py-2 text-sm rounded bg-blue-600 text-white cursor-pointer hover:bg-blue-700 text-center">
|
||||
{t('page.image.upload')}
|
||||
<label className="block cursor-pointer text-center">
|
||||
<span className="inline-flex items-center justify-center px-3 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||
{t('page.image.upload')}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'success' | 'danger' | 'ghost'
|
||||
export type ButtonSize = 'small' | 'medium' | 'large'
|
||||
@ -13,6 +14,11 @@ interface ButtonProps {
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
className?: string
|
||||
'aria-label'?: string
|
||||
'aria-expanded'?: boolean
|
||||
'aria-haspopup'?: boolean | 'true' | 'false' | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'
|
||||
'aria-selected'?: boolean
|
||||
role?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
function getVariantClasses(variant: ButtonVariant): string {
|
||||
@ -70,17 +76,42 @@ function LoadingSpinner(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
function ButtonContent({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onClick,
|
||||
type = 'button',
|
||||
className = '',
|
||||
'aria-label': ariaLabel,
|
||||
}: ButtonProps): React.ReactElement {
|
||||
loading,
|
||||
}: {
|
||||
children: ReactNode
|
||||
loading: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<span className="mr-2">
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref): React.ReactElement => {
|
||||
const {
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
onClick,
|
||||
type = 'button',
|
||||
className = '',
|
||||
'aria-label': ariaLabel,
|
||||
'aria-expanded': ariaExpanded,
|
||||
'aria-haspopup': ariaHaspopup,
|
||||
'aria-selected': ariaSelected,
|
||||
role: roleProp,
|
||||
id,
|
||||
} = props
|
||||
const variantClasses = getVariantClasses(variant)
|
||||
const sizeClasses = getSizeClasses(size)
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all border focus:outline-none focus:ring-2 focus:ring-neon-cyan disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
@ -89,19 +120,22 @@ export function Button({
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
id={id}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={combinedClasses}
|
||||
role={roleProp}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-haspopup={ariaHaspopup}
|
||||
aria-selected={ariaSelected}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading && (
|
||||
<span className="mr-2">
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<ButtonContent loading={loading}>{children}</ButtonContent>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Button.displayName = 'Button'
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Button } from '../ui'
|
||||
|
||||
export function UnlockAccountButtons(params: {
|
||||
loading: boolean
|
||||
words: string[]
|
||||
@ -6,21 +8,24 @@ export function UnlockAccountButtons(params: {
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={params.onClose}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
|
||||
className="flex-1"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={params.onUnlock}
|
||||
disabled={params.loading || params.words.some((word) => !word)}
|
||||
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
|
||||
loading={params.loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{params.loading ? 'Déverrouillage...' : 'Déverrouiller'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Button } from '../ui'
|
||||
import { WordInputWithAutocomplete } from './WordInputWithAutocomplete'
|
||||
|
||||
export function UnlockAccountForm(params: {
|
||||
@ -8,13 +9,15 @@ export function UnlockAccountForm(params: {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<WordInputsGrid words={params.words} onWordChange={params.onWordChange} />
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={params.onPaste}
|
||||
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
|
||||
>
|
||||
Coller depuis le presse-papiers
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { Button, Card } from '../ui'
|
||||
|
||||
export function WordSuggestions(params: {
|
||||
showSuggestions: boolean
|
||||
suggestions: string[]
|
||||
@ -8,17 +10,18 @@ export function WordSuggestions(params: {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto">
|
||||
<Card variant="default" className="absolute z-10 w-full mt-1 bg-white border-gray-300 shadow-lg max-h-40 overflow-y-auto">
|
||||
{params.suggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
<Button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => params.onSuggestionClick(suggestion)}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${idx === params.selectedIndex ? 'bg-gray-100' : ''}`}
|
||||
className={`w-full text-left justify-start ${idx === params.selectedIndex ? 'bg-gray-100' : ''}`}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,45 +69,25 @@ Les composants suivants ont été créés dans `components/ui/` :
|
||||
|
||||
## Composants restants à migrer
|
||||
|
||||
### Priorité haute
|
||||
1. **`SeriesCard.tsx`** - Le container principal utilise encore un `div` avec styles inline
|
||||
2. **`createSeriesModal/CreateSeriesModalView.tsx`** - Le container de la modal utilise un `div` avec styles inline
|
||||
3. **`AuthorFilterButton.tsx`** - Le bouton principal utilise encore un `button` avec styles inline
|
||||
4. **`AuthorFilterDropdown.tsx`** - Les boutons d'option utilisent encore des `button` avec styles inline
|
||||
5. **`CategoryTabs.tsx`** - Les boutons d'onglets utilisent encore des `button` avec styles inline
|
||||
Aucun composant prioritaire restant. Tous les composants principaux ont été migrés vers les composants UI réutilisables.
|
||||
|
||||
### Priorité moyenne
|
||||
6. **`CreateAccountModalSteps.tsx`** - Containers de modales avec `div` et styles inline
|
||||
7. **`LanguageSettingsManager.tsx`** - Containers avec `div` et styles inline
|
||||
8. **`FundingGauge.tsx`** - Containers avec `div` et styles inline
|
||||
9. **`DocsContent.tsx`** - Container principal avec `div` et styles inline
|
||||
10. **`DocsSidebar.tsx`** - Container avec `div` et styles inline
|
||||
11. **`keyManagement/KeyManagementRecoverySection.tsx`** - Containers et boutons avec styles inline
|
||||
12. **`keyManagement/KeyManagementManager.tsx`** - Containers avec `div` et styles inline
|
||||
13. **`keyManagement/KeyManagementImportForm.tsx`** - Boutons avec styles inline
|
||||
14. **`syncProgressBar/SyncProgressBar.tsx`** - Container avec `div` et styles inline
|
||||
15. **`AlbyInstaller.tsx`** - Container avec `div` et styles inline
|
||||
16. **`ConditionalPublishButton.tsx`** - Le link container utilise encore un `Link` avec styles inline
|
||||
17. **`NotificationActions.tsx`** - Boutons avec styles inline
|
||||
18. **`NotificationBadge.tsx`** - Bouton avec styles inline
|
||||
19. **`NotificationBadgeButton.tsx`** - Bouton avec styles inline
|
||||
20. **`NotificationPanelHeader.tsx`** - Boutons avec styles inline
|
||||
21. **`ProfileBackButton.tsx`** - Bouton avec styles inline
|
||||
22. **`unlockAccount/UnlockAccountForm.tsx`** - Formulaires et boutons
|
||||
23. **`unlockAccount/UnlockAccountButtons.tsx`** - Boutons
|
||||
24. **`unlockAccount/WordSuggestions.tsx`** - Suggestions
|
||||
25. **`markdownEditorTwoColumns/MarkdownEditorTwoColumns.tsx`** - Boutons et labels
|
||||
26. **`markdownEditorTwoColumns/PagesManager.tsx`** - Boutons et labels
|
||||
27. **`ImageUploadField.tsx`** - Le label d'upload utilise encore un `span` avec styles inline
|
||||
28. **`ArticleEditorForm.tsx`** - Container d'erreur avec `div` et styles inline
|
||||
29. **`LanguageSelector.tsx`** - Sélecteur de langue
|
||||
30. **`authorPage/AuthorPageContent.tsx`** - Container avec `div` et styles inline
|
||||
31. **`UserProfile.tsx`** - Container avec `div` et styles inline
|
||||
6. **`ImageUploadField.tsx`** - Le label d'upload utilise encore un `span` avec styles inline (déjà partiellement migré avec Button pour RemoveButton)
|
||||
|
||||
### Priorité basse
|
||||
32. **`relayManager/RelayCard.tsx`** - Le container principal utilise encore un `div` avec styles inline
|
||||
33. **`nip95Config/Nip95ApiCard.tsx`** - Le container principal utilise encore un `div` avec styles inline
|
||||
34. **`PaymentModal.tsx`** - Le container du QR code utilise encore un `div` avec styles inline
|
||||
10. **`PaymentModal.tsx`** - Le container du QR code utilise encore un `div` avec styles inline (déjà partiellement migré)
|
||||
|
||||
## Composants récemment migrés
|
||||
- ✅ `LanguageSelector.tsx` - Migration vers Button
|
||||
- ✅ `NotificationBadge.tsx` - Migration vers Button
|
||||
- ✅ `NotificationBadgeButton.tsx` - Migration vers Button
|
||||
- ✅ `ClearButton.tsx` - Migration vers Button
|
||||
- ✅ `keyManagement/KeyManagementImportSection.tsx` - Migration de KeyManagementNoAccountBanner vers Card et KeyManagementImportButton vers Button
|
||||
- ✅ `ArticleEditor.tsx` - Migration de SuccessMessage vers Card
|
||||
- ✅ `NotificationPanel.tsx` - Migration du conteneur principal vers Card
|
||||
- ✅ `AuthorFilterButton.tsx` - Migration vers Button avec support forwardRef
|
||||
- ✅ `AuthorFilterDropdown.tsx` - Migration de AuthorOption et AllAuthorsOption vers Button
|
||||
- ✅ `components/ui/Button.tsx` - Ajout du support forwardRef, aria-expanded, aria-haspopup, aria-selected, role, id
|
||||
|
||||
## Erreurs corrigées
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user