Upload folder using huggingface_hub
Browse files- client/src/components/Refinity.tsx +239 -0
- client/src/pages/Toolkit.tsx +10 -10
client/src/components/Refinity.tsx
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
type Stage = 'task' | 'flow' | 'editor';
|
| 4 |
+
|
| 5 |
+
type Version = {
|
| 6 |
+
id: string;
|
| 7 |
+
taskId: string;
|
| 8 |
+
originalAuthor: string;
|
| 9 |
+
revisedBy?: string;
|
| 10 |
+
versionNumber: number; // 1-based
|
| 11 |
+
content: string; // translated text
|
| 12 |
+
parentVersionId?: string; // lineage
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
type Task = {
|
| 16 |
+
id: string;
|
| 17 |
+
title: string;
|
| 18 |
+
sourceText: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const mockTasks: Task[] = [
|
| 22 |
+
{ id: 't1', title: 'Refinity Demo Task 1', sourceText: 'The quick brown fox jumps over the lazy dog.' },
|
| 23 |
+
{ id: 't2', title: 'Refinity Demo Task 2', sourceText: 'To be, or not to be, that is the question.' },
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const Refinity: React.FC = () => {
|
| 27 |
+
const [stage, setStage] = React.useState<Stage>('task');
|
| 28 |
+
const [tasks] = React.useState<Task[]>(mockTasks);
|
| 29 |
+
const [selectedTaskId, setSelectedTaskId] = React.useState<string>(mockTasks[0]?.id || '');
|
| 30 |
+
const [versions, setVersions] = React.useState<Version[]>([]);
|
| 31 |
+
const [currentVersionId, setCurrentVersionId] = React.useState<string | null>(null);
|
| 32 |
+
const [username, setUsername] = React.useState<string>(() => {
|
| 33 |
+
try {
|
| 34 |
+
const u = localStorage.getItem('user');
|
| 35 |
+
const name = u ? (JSON.parse(u)?.name || JSON.parse(u)?.email || 'Anonymous') : 'Anonymous';
|
| 36 |
+
return String(name);
|
| 37 |
+
} catch {
|
| 38 |
+
return 'Anonymous';
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// File upload (.docx placeholder)
|
| 43 |
+
const [uploading, setUploading] = React.useState(false);
|
| 44 |
+
|
| 45 |
+
const task = React.useMemo(() => tasks.find(t => t.id === selectedTaskId) || tasks[0], [tasks, selectedTaskId]);
|
| 46 |
+
const taskVersions = React.useMemo(() => versions.filter(v => v.taskId === (task?.id || '')), [versions, task?.id]);
|
| 47 |
+
|
| 48 |
+
const focusedIndex = React.useMemo(() => {
|
| 49 |
+
const idx = taskVersions.findIndex(v => v.id === currentVersionId);
|
| 50 |
+
return idx >= 0 ? idx : (taskVersions.length ? taskVersions.length - 1 : -1);
|
| 51 |
+
}, [taskVersions, currentVersionId]);
|
| 52 |
+
|
| 53 |
+
const uploadDocx = async (file: File) => {
|
| 54 |
+
setUploading(true);
|
| 55 |
+
try {
|
| 56 |
+
// Placeholder: In a real implementation, parse .docx client-side or via backend
|
| 57 |
+
// For now, create a mock text from filename
|
| 58 |
+
const baseText = `Uploaded translation from ${username}: ${file.name}`;
|
| 59 |
+
const nextVersionNumber = (taskVersions[taskVersions.length - 1]?.versionNumber || 0) + 1;
|
| 60 |
+
const newVersion: Version = {
|
| 61 |
+
id: `v_${Date.now()}`,
|
| 62 |
+
taskId: task?.id || 't1',
|
| 63 |
+
originalAuthor: username,
|
| 64 |
+
revisedBy: undefined,
|
| 65 |
+
versionNumber: nextVersionNumber,
|
| 66 |
+
content: baseText,
|
| 67 |
+
};
|
| 68 |
+
setVersions(prev => [...prev, newVersion]);
|
| 69 |
+
setCurrentVersionId(newVersion.id);
|
| 70 |
+
setStage('flow');
|
| 71 |
+
} finally {
|
| 72 |
+
setUploading(false);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const assignRandom = () => {
|
| 77 |
+
const pool = taskVersions.filter(v => (v.originalAuthor !== username && v.revisedBy !== username));
|
| 78 |
+
if (!pool.length) return;
|
| 79 |
+
const pick = pool[Math.floor(Math.random() * pool.length)];
|
| 80 |
+
setCurrentVersionId(pick.id);
|
| 81 |
+
setStage('editor');
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const selectManual = (id: string) => {
|
| 85 |
+
setCurrentVersionId(id);
|
| 86 |
+
setStage('editor');
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const handleSaveRevision = (newContent: string) => {
|
| 90 |
+
const parent = taskVersions.find(v => v.id === currentVersionId);
|
| 91 |
+
const nextVersionNumber = (taskVersions[taskVersions.length - 1]?.versionNumber || 0) + 1;
|
| 92 |
+
const v: Version = {
|
| 93 |
+
id: `v_${Date.now()}`,
|
| 94 |
+
taskId: task?.id || 't1',
|
| 95 |
+
originalAuthor: parent?.originalAuthor || username,
|
| 96 |
+
revisedBy: username,
|
| 97 |
+
versionNumber: nextVersionNumber,
|
| 98 |
+
content: newContent,
|
| 99 |
+
parentVersionId: parent?.id,
|
| 100 |
+
};
|
| 101 |
+
setVersions(prev => [...prev, v]);
|
| 102 |
+
setCurrentVersionId(v.id);
|
| 103 |
+
setStage('flow');
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<div className="bg-slate-900 text-slate-50">
|
| 108 |
+
{/* Stage 1: Task Creation / Selection */}
|
| 109 |
+
{stage === 'task' && (
|
| 110 |
+
<div className="p-6">
|
| 111 |
+
<div className="mb-6">
|
| 112 |
+
<h2 className="text-2xl font-semibold">Refinity</h2>
|
| 113 |
+
<p className="text-slate-300">Infinite peer-based translation revision</p>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
|
| 116 |
+
<div className="md:col-span-1">
|
| 117 |
+
<label className="block text-sm text-slate-300 mb-1">Your name</label>
|
| 118 |
+
<input value={username} onChange={(e)=>setUsername(e.target.value)} className="w-full px-3 py-2 rounded-md bg-slate-800 border border-slate-700 text-slate-100" />
|
| 119 |
+
</div>
|
| 120 |
+
<div className="md:col-span-1">
|
| 121 |
+
<label className="block text-sm text-slate-300 mb-1">Task</label>
|
| 122 |
+
<select value={selectedTaskId} onChange={(e)=>setSelectedTaskId(e.target.value)} className="w-full px-3 py-2 rounded-md bg-slate-800 border border-slate-700 text-slate-100">
|
| 123 |
+
{tasks.map(t => <option key={t.id} value={t.id}>{t.title}</option>)}
|
| 124 |
+
</select>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="md:col-span-1">
|
| 127 |
+
<label className="block text-sm text-slate-300 mb-1">Upload .docx translation</label>
|
| 128 |
+
<input type="file" accept=".docx" onChange={(e)=>{ const f=e.target.files?.[0]; if(f) uploadDocx(f); }} className="block w-full text-slate-200 file:px-3 file:py-2 file:rounded-md file:border-0 file:bg-indigo-600 file:text-white hover:file:bg-indigo-700 disabled:file:bg-slate-700" disabled={uploading} />
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div className="mt-8 flex gap-3">
|
| 133 |
+
<button onClick={assignRandom} className="px-4 py-2 rounded-2xl bg-indigo-600 hover:bg-indigo-700">Start Revising (Random)</button>
|
| 134 |
+
<button onClick={()=>setStage('flow')} className="px-4 py-2 rounded-2xl bg-slate-800 border border-slate-700">Choose Manually</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
|
| 139 |
+
{/* Stage 2: Version Flow */}
|
| 140 |
+
{stage === 'flow' && (
|
| 141 |
+
<div className="p-6">
|
| 142 |
+
<div className="flex items-center justify-between mb-4">
|
| 143 |
+
<div>
|
| 144 |
+
<h3 className="text-xl font-semibold">Version Flow — {task?.title}</h3>
|
| 145 |
+
<div className="text-slate-400 text-sm">Scroll to browse. Center card is focused.</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="flex gap-2">
|
| 148 |
+
<button onClick={()=>setStage('task')} className="px-3 py-2 rounded-md bg-slate-800 border border-slate-700">Back</button>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div className="relative overflow-x-auto py-10">
|
| 153 |
+
<div className="flex items-center gap-8 min-w-max px-6">
|
| 154 |
+
{taskVersions.map((v, idx) => {
|
| 155 |
+
const isCenter = idx === focusedIndex;
|
| 156 |
+
const sideOffset = Math.abs(idx - focusedIndex);
|
| 157 |
+
const scale = isCenter ? 1.0 : Math.max(0.8, 1 - sideOffset * 0.08);
|
| 158 |
+
const rotate = isCenter ? 0 : (idx < focusedIndex ? -12 : 12);
|
| 159 |
+
const opacity = isCenter ? 1 : Math.max(0.35, 1 - sideOffset * 0.2);
|
| 160 |
+
return (
|
| 161 |
+
<div key={v.id} className="transition-transform duration-300"
|
| 162 |
+
style={{ transform: `scale(${scale}) rotateY(${rotate}deg)`, opacity }}>
|
| 163 |
+
<div className={`w-[520px] bg-slate-800 border border-slate-700 rounded-xl shadow-xl p-5`}
|
| 164 |
+
onClick={()=>setCurrentVersionId(v.id)}>
|
| 165 |
+
<div className="text-slate-200 text-sm mb-2">Original: {v.originalAuthor}</div>
|
| 166 |
+
<div className="text-slate-400 text-xs mb-3">Revised by: {v.revisedBy ? `${v.revisedBy} (v${v.versionNumber})` : `— (v${v.versionNumber})`}</div>
|
| 167 |
+
<div className="text-slate-100 whitespace-pre-wrap break-words min-h-[160px]">{v.content}</div>
|
| 168 |
+
{isCenter && (
|
| 169 |
+
<div className="mt-4 flex gap-3">
|
| 170 |
+
<button className="px-3 py-2 rounded-md bg-slate-700 border border-slate-600">Compare</button>
|
| 171 |
+
<button className="px-3 py-2 rounded-md bg-slate-700 border border-slate-600">Comment</button>
|
| 172 |
+
<button onClick={()=>selectManual(v.id)} className="px-3 py-2 rounded-md bg-indigo-600">Edit</button>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
);
|
| 178 |
+
})}
|
| 179 |
+
</div>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
|
| 184 |
+
{/* Stage 3: Editor */}
|
| 185 |
+
{stage === 'editor' && (
|
| 186 |
+
<EditorPane
|
| 187 |
+
source={task?.sourceText || ''}
|
| 188 |
+
initialTranslation={taskVersions.find(v => v.id === currentVersionId)?.content || ''}
|
| 189 |
+
onBack={()=>setStage('flow')}
|
| 190 |
+
onSave={handleSaveRevision}
|
| 191 |
+
/>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
const EditorPane: React.FC<{ source: string; initialTranslation: string; onBack: ()=>void; onSave: (text: string)=>void }>=({ source, initialTranslation, onBack, onSave })=>{
|
| 198 |
+
const [text, setText] = React.useState<string>(initialTranslation);
|
| 199 |
+
const [saving, setSaving] = React.useState(false);
|
| 200 |
+
|
| 201 |
+
const save = async ()=>{
|
| 202 |
+
setSaving(true);
|
| 203 |
+
try {
|
| 204 |
+
onSave(text);
|
| 205 |
+
} finally {
|
| 206 |
+
setSaving(false);
|
| 207 |
+
}
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
const downloadWithTrackChanges = async ()=>{
|
| 211 |
+
// Placeholder: Implement docx generation with track changes via backend or client lib
|
| 212 |
+
alert('Download with Track Changes is a placeholder in this prototype.');
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<div className="p-6">
|
| 217 |
+
<div className="flex items-start gap-6">
|
| 218 |
+
<div className="w-1/2">
|
| 219 |
+
<div className="mb-2 text-slate-300 text-sm">Source</div>
|
| 220 |
+
<div className="rounded-lg bg-slate-800 border border-slate-700 p-4 min-h-[420px] whitespace-pre-wrap">{source}</div>
|
| 221 |
+
</div>
|
| 222 |
+
<div className="w-1/2">
|
| 223 |
+
<div className="mb-2 text-slate-300 text-sm">Translation</div>
|
| 224 |
+
<textarea value={text} onChange={(e)=>setText(e.target.value)} className="w-full h-[420px] rounded-lg bg-slate-900 border border-slate-700 p-4 text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-600" />
|
| 225 |
+
<div className="mt-4 flex gap-3">
|
| 226 |
+
<button onClick={save} disabled={saving} className="px-4 py-2 rounded-2xl bg-indigo-600 disabled:bg-slate-700">{saving? 'Saving…':'Save'}</button>
|
| 227 |
+
<button className="px-4 py-2 rounded-2xl bg-slate-800 border border-slate-700">Compare</button>
|
| 228 |
+
<button onClick={downloadWithTrackChanges} className="px-4 py-2 rounded-2xl bg-slate-800 border border-slate-700">Download with Track Changes</button>
|
| 229 |
+
<button onClick={onBack} className="ml-auto px-4 py-2 rounded-2xl bg-slate-800 border border-slate-700">Back</button>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
);
|
| 235 |
+
};
|
| 236 |
+
|
| 237 |
+
export default Refinity;
|
| 238 |
+
|
| 239 |
+
|
client/src/pages/Toolkit.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
-
import
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import {
|
| 5 |
DocumentTextIcon,
|
|
@@ -9,7 +9,7 @@ import {
|
|
| 9 |
ArrowsRightLeftIcon
|
| 10 |
} from '@heroicons/react/24/outline';
|
| 11 |
|
| 12 |
-
type ToolKey = 'quality-lens' | 'mymemory' | 'dictionary' | '
|
| 13 |
|
| 14 |
interface MyMemoryResponse {
|
| 15 |
responseData?: {
|
|
@@ -50,7 +50,7 @@ const TOOL_URLS: Record<ToolKey, string> = {
|
|
| 50 |
'quality-lens': 'https://linguabot-quality-lens.hf.space',
|
| 51 |
'mymemory': '',
|
| 52 |
'dictionary': '',
|
| 53 |
-
'
|
| 54 |
'mt': '',
|
| 55 |
'links': ''
|
| 56 |
};
|
|
@@ -124,7 +124,7 @@ const Toolkit: React.FC = () => {
|
|
| 124 |
{ key: 'quality-lens' as ToolKey, name: 'Quality Lens', desc: 'BLASER/COMET QE + Hallucination', type: 'iframe' },
|
| 125 |
{ key: 'mymemory' as ToolKey, name: 'MyMemory', desc: 'Public MT memory lookup', type: 'native' },
|
| 126 |
{ key: 'dictionary' as ToolKey, name: 'Dictionary (EN⇄ZH)', desc: 'Iciba suggest', type: 'native' },
|
| 127 |
-
{ key: '
|
| 128 |
{ key: 'links' as ToolKey, name: 'Useful Links', desc: 'Curated external resources', type: 'native' }
|
| 129 |
];
|
| 130 |
if (!isVisitor) base.splice(2, 0, { key: 'mt' as ToolKey, name: 'MT (DeepL/Google)', desc: 'Translate with MT engines', type: 'native' });
|
|
@@ -613,19 +613,19 @@ const Toolkit: React.FC = () => {
|
|
| 613 |
</div>
|
| 614 |
)}
|
| 615 |
|
| 616 |
-
{selectedTool === '
|
| 617 |
<div>
|
| 618 |
<div className="flex items-center space-x-3 mb-4">
|
| 619 |
-
<div className="bg-
|
| 620 |
-
<
|
| 621 |
</div>
|
| 622 |
<div>
|
| 623 |
-
<h3 className="text-
|
| 624 |
-
<p className="text-gray-600 text-sm">
|
| 625 |
</div>
|
| 626 |
</div>
|
| 627 |
<div className="border rounded-lg overflow-hidden">
|
| 628 |
-
<
|
| 629 |
</div>
|
| 630 |
</div>
|
| 631 |
)}
|
|
|
|
| 1 |
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import Refinity from '../components/Refinity';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import {
|
| 5 |
DocumentTextIcon,
|
|
|
|
| 9 |
ArrowsRightLeftIcon
|
| 10 |
} from '@heroicons/react/24/outline';
|
| 11 |
|
| 12 |
+
type ToolKey = 'quality-lens' | 'mymemory' | 'dictionary' | 'refinity' | 'mt' | 'links';
|
| 13 |
|
| 14 |
interface MyMemoryResponse {
|
| 15 |
responseData?: {
|
|
|
|
| 50 |
'quality-lens': 'https://linguabot-quality-lens.hf.space',
|
| 51 |
'mymemory': '',
|
| 52 |
'dictionary': '',
|
| 53 |
+
'refinity': '',
|
| 54 |
'mt': '',
|
| 55 |
'links': ''
|
| 56 |
};
|
|
|
|
| 124 |
{ key: 'quality-lens' as ToolKey, name: 'Quality Lens', desc: 'BLASER/COMET QE + Hallucination', type: 'iframe' },
|
| 125 |
{ key: 'mymemory' as ToolKey, name: 'MyMemory', desc: 'Public MT memory lookup', type: 'native' },
|
| 126 |
{ key: 'dictionary' as ToolKey, name: 'Dictionary (EN⇄ZH)', desc: 'Iciba suggest', type: 'native' },
|
| 127 |
+
{ key: 'refinity' as ToolKey, name: 'Refinity', desc: 'Infinite peer-based revision', type: 'native' },
|
| 128 |
{ key: 'links' as ToolKey, name: 'Useful Links', desc: 'Curated external resources', type: 'native' }
|
| 129 |
];
|
| 130 |
if (!isVisitor) base.splice(2, 0, { key: 'mt' as ToolKey, name: 'MT (DeepL/Google)', desc: 'Translate with MT engines', type: 'native' });
|
|
|
|
| 613 |
</div>
|
| 614 |
)}
|
| 615 |
|
| 616 |
+
{selectedTool === 'refinity' && (
|
| 617 |
<div>
|
| 618 |
<div className="flex items-center space-x-3 mb-4">
|
| 619 |
+
<div className="bg-indigo-600 rounded-lg p-2">
|
| 620 |
+
<WrenchScrewdriverIcon className="h-5 w-5 text-white" />
|
| 621 |
</div>
|
| 622 |
<div>
|
| 623 |
+
<h3 className="text-indigo-900 font-semibold text-xl">Refinity</h3>
|
| 624 |
+
<p className="text-gray-600 text-sm">An elegant, collaborative, infinite revision platform.</p>
|
| 625 |
</div>
|
| 626 |
</div>
|
| 627 |
<div className="border rounded-lg overflow-hidden">
|
| 628 |
+
<Refinity />
|
| 629 |
</div>
|
| 630 |
</div>
|
| 631 |
)}
|