feat: replace quill with the tiptap editor (#400)

* feat: initial tiptap

* feat: initial tiptap

* chore: styling

* feat: add link

* chore: add editor styling

* feat: add password injection button

* fix: make the editor work on the secret route

* feat: add read only menu

* chore: remove test content

* fix: remove quill

* fix: set read only after secret is created

* fix: ensure read only

* chore: update the height

* chore: set correct colros for the title bg

* chore: use generate password dep

* feat: enable all password features

* fix: clear the editor on create new

* feat: use translation

* feat: add languages

* chore: remove unused file

* fix: replace with useeffect

* chore: pass extra props
This commit is contained in:
bjarneo 2025-02-25 12:05:37 +01:00 committed by GitHub
parent f8740e37b0
commit c75fef10c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2662 additions and 1265 deletions

View File

@ -0,0 +1,736 @@
import {
IconArrowBackUp,
IconArrowForwardUp,
IconBinary,
IconBold,
IconBrandCodesandbox,
IconCode,
IconCopy,
IconH1,
IconH2,
IconH3,
IconItalic,
IconKey,
IconLetterP,
IconLink,
IconLinkOff,
IconList,
IconListNumbers,
IconQuote,
IconStrikethrough,
IconTextCaption,
IconX,
} from '@tabler/icons';
import { Color } from '@tiptap/extension-color';
import Link from '@tiptap/extension-link';
import ListItem from '@tiptap/extension-list-item';
import TextStyle from '@tiptap/extension-text-style';
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { generate } from 'generate-password-browser';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
const generatePassword = (
length = 16,
options = { numbers: true, symbols: true, uppercase: true, lowercase: true }
) => {
const password = generate({
length,
numbers: options.numbers,
symbols: options.symbols,
uppercase: options.uppercase,
lowercase: options.lowercase,
});
return password;
};
// Tooltip component for buttons
const Tooltip = ({ text, children }) => {
const [isVisible, setIsVisible] = useState(false);
return (
<div className="relative inline-block">
<div onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => setIsVisible(false)}>
{children}
</div>
{isVisible && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs font-medium text-white bg-gray-800 rounded shadow-sm whitespace-nowrap z-10">
{text}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-800"></div>
</div>
)}
</div>
);
};
// Link Modal Component
const LinkModal = ({ isOpen, onClose, onSubmit, initialUrl = '' }) => {
const [url, setUrl] = useState(initialUrl);
const inputRef = useRef(null);
const { t } = useTranslation();
// Focus the input when the modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => {
inputRef.current.focus();
}, 50);
}
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(url);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-100">
{t('editor.link_modal.title')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<IconX size={20} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="url"
className="block text-sm font-medium text-gray-300 mb-2"
>
{t('editor.link_modal.url_label')}
</label>
<input
ref={inputRef}
type="text"
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t('editor.link_modal.url_placeholder')}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 text-gray-200 rounded-md hover:bg-gray-600"
>
{t('editor.link_modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded-md "
>
{initialUrl
? t('editor.link_modal.update')
: t('editor.link_modal.insert')}
</button>
</div>
</form>
</div>
</div>
);
};
// Password Modal Component
const PasswordModal = ({ isOpen, onClose, onSubmit }) => {
const [passwordLength, setPasswordLength] = useState(16);
const [password, setPassword] = useState(generatePassword(16));
const [options, setOptions] = useState({
numbers: true,
symbols: true,
uppercase: true,
lowercase: true,
});
const { t } = useTranslation();
if (!isOpen) return null;
const regeneratePassword = () => {
setPassword(generatePassword(passwordLength, options));
};
const handleOptionChange = (option) => {
// Prevent disabling all options - at least one must be enabled
const newOptions = { ...options, [option]: !options[option] };
if (Object.values(newOptions).some((value) => value)) {
setOptions(newOptions);
setPassword(generatePassword(passwordLength, newOptions));
}
};
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(password);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-100">
{t('editor.password_modal.title')}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200">
<IconX size={20} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
{t('editor.password_modal.length_label')}
</label>
<div className="flex items-center mb-4">
<input
type="range"
min="8"
max="32"
value={passwordLength}
onChange={(e) => {
const newLength = parseInt(e.target.value);
setPasswordLength(newLength);
setPassword(generatePassword(newLength, options));
}}
className="w-full mr-3 accent-primary"
style={{
// Fallback for browsers that don't support accent-color
'--tw-accent-color': 'var(--color-primary)',
}}
/>
<span className="text-gray-200 w-8 text-center">{passwordLength}</span>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
{t('editor.password_modal.options_label')}
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center">
<input
type="checkbox"
id="numbers"
checked={options.numbers}
onChange={() => handleOptionChange('numbers')}
className="mr-2 checked:bg-primary checked:hover:bg-primary/80 checked:focus:bg-primary/60 checked:active:bg-primary/60"
/>
<label htmlFor="numbers" className="text-gray-300 text-sm">
{t('editor.password_modal.include_numbers')}
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="symbols"
checked={options.symbols}
onChange={() => handleOptionChange('symbols')}
className="mr-2 checked:bg-primary checked:hover:bg-primary/80 checked:active:bg-primary/60 checked:focus:bg-primary/60"
/>
<label htmlFor="symbols" className="text-gray-300 text-sm">
{t('editor.password_modal.include_symbols')}
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="uppercase"
checked={options.uppercase}
onChange={() => handleOptionChange('uppercase')}
className="mr-2 checked:bg-primary checked:hover:bg-primary/80 checked:active:bg-primary/60 checked:focus:bg-primary/60"
/>
<label htmlFor="uppercase" className="text-gray-300 text-sm">
{t('editor.password_modal.include_uppercase')}
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="lowercase"
checked={options.lowercase}
onChange={() => handleOptionChange('lowercase')}
className="mr-2 checked:bg-primary checked:hover:bg-primary/80 checked:active:bg-primary/60 checked:focus:bg-primary/60"
/>
<label htmlFor="lowercase" className="text-gray-300 text-sm">
{t('editor.password_modal.include_lowercase')}
</label>
</div>
</div>
</div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{t('editor.password_modal.generated_password')}
</label>
<div className="flex">
<input
type="text"
value={password}
readOnly
className="w-full px-3 py-2 bg-gray-900 border border-gray-600 rounded-l-md text-gray-100"
/>
<button
type="button"
onClick={regeneratePassword}
className="px-3 py-2 bg-primary text-gray-200 rounded-r-md hover:opacity-90"
>
{t('editor.password_modal.refresh')}
</button>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 text-gray-200 rounded-md hover:bg-gray-600"
>
{t('editor.password_modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-primary text-white rounded-md hover:opacity-90"
>
{t('editor.password_modal.insert')}
</button>
</div>
</form>
</div>
</div>
);
};
// ReadOnlyMenuBar component for non-editable mode
const ReadOnlyMenuBar = () => {
const { editor } = useCurrentEditor();
const [copySuccess, setCopySuccess] = useState('');
const { t } = useTranslation();
if (!editor) {
return null;
}
const copyAsHTML = () => {
const html = editor.getHTML();
navigator.clipboard
.writeText(html)
.then(() => {
setCopySuccess(t('editor.copy_success.html'));
setTimeout(() => setCopySuccess(''), 2000);
})
.catch((err) => {
console.error('Failed to copy: ', err);
});
};
const copyAsPlainText = () => {
const text = editor.getText();
navigator.clipboard
.writeText(text)
.then(() => {
setCopySuccess(t('editor.copy_success.text'));
setTimeout(() => setCopySuccess(''), 2000);
})
.catch((err) => {
console.error('Failed to copy: ', err);
});
};
const copyAsBase64 = () => {
const text = editor.getText();
// Convert to Base64
const base64Content = btoa(
new TextEncoder()
.encode(text)
.reduce((acc, byte) => acc + String.fromCharCode(byte), '')
);
navigator.clipboard
.writeText(base64Content)
.then(() => {
setCopySuccess(t('editor.copy_success.base64'));
setTimeout(() => setCopySuccess(''), 2000);
})
.catch((err) => {
console.error('Failed to copy: ', err);
});
};
const buttonClass =
'p-1.5 text-sm rounded-md hover:bg-gray-700 text-gray-200 transition-colors';
const groupClass = 'flex items-center border border-gray-700 rounded-md bg-gray-800 shadow-sm';
return (
<div className="mb-4 flex w-full">
<div className="flex gap-2">
<div className={groupClass}>
<Tooltip text={t('editor.tooltips.copy_html')}>
<button onClick={copyAsHTML} className={buttonClass}>
<IconCopy size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.copy_text')}>
<button onClick={copyAsPlainText} className={buttonClass}>
<IconTextCaption size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.copy_base64')}>
<button onClick={copyAsBase64} className={buttonClass}>
<IconBinary size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
</div>
</div>
{copySuccess && (
<div className="text-sm text-green-400 animate-fade-in-out">{copySuccess}</div>
)}
</div>
);
};
const MenuBar = ({ content }) => {
const { editor } = useCurrentEditor();
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const { t } = useTranslation();
if (!editor) {
return null;
}
// If the content is empty, clear the editor content
// this is a hack until I figure out how to handle this better
useEffect(() => {
if (content === '') {
editor.commands.clearContent();
}
}, []);
// Updated button styles without dark mode prefixes
const buttonClass =
'p-1.5 text-sm rounded-md hover:bg-gray-700 text-gray-200 transition-colors';
const activeButtonClass =
'p-1.5 text-sm rounded-md bg-blue-900/30 text-blue-400 transition-colors';
// Updated group styles without dark mode prefixes
const groupClass = 'flex items-center border border-gray-700 rounded-md bg-gray-800 shadow-sm';
// Updated link handling function
const openLinkModal = useCallback(() => {
const previousUrl = editor.getAttributes('link').href || '';
setLinkModalOpen(true);
}, [editor]);
const handleLinkSubmit = useCallback(
(url) => {
// Empty
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
// Add protocol if missing
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
// Update link
try {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
} catch (e) {
alert(e.message);
}
},
[editor]
);
// Password handling function
const handlePasswordSubmit = useCallback(
(password) => {
editor.chain().focus().insertContent(password).run();
},
[editor]
);
return (
<>
<div className="mb-4">
<div className="flex flex-wrap gap-2 items-center">
{/* Text formatting group */}
<div className={groupClass}>
<Tooltip text={t('editor.tooltips.bold')}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={
editor.isActive('bold') ? activeButtonClass : buttonClass
}
>
<IconBold size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.italic')}>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={
editor.isActive('italic') ? activeButtonClass : buttonClass
}
>
<IconItalic size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.strikethrough')}>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={
editor.isActive('strike') ? activeButtonClass : buttonClass
}
>
<IconStrikethrough
size={18}
stroke={1.5}
className="text-gray-300"
/>
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.inline_code')}>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={
editor.isActive('code') ? activeButtonClass : buttonClass
}
>
<IconCode size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.link')}>
<button
onClick={openLinkModal}
className={
editor.isActive('link') ? activeButtonClass : buttonClass
}
>
<IconLink size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.remove_link')}>
<button
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={!editor.isActive('link')}
className={`${buttonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<IconLinkOff size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.insert_password')}>
<button
onClick={() => setPasswordModalOpen(true)}
className={buttonClass}
>
<IconKey size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
</div>
{/* Paragraph formatting group */}
<div className={groupClass}>
<Tooltip text={t('editor.tooltips.paragraph')}>
<button
onClick={() => editor.chain().focus().setParagraph().run()}
className={
editor.isActive('paragraph') ? activeButtonClass : buttonClass
}
>
<IconLetterP size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.heading1')}>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
className={
editor.isActive('heading', { level: 1 })
? activeButtonClass
: buttonClass
}
>
<IconH1 size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.heading2')}>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={
editor.isActive('heading', { level: 2 })
? activeButtonClass
: buttonClass
}
>
<IconH2 size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.heading3')}>
<button
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={
editor.isActive('heading', { level: 3 })
? activeButtonClass
: buttonClass
}
>
<IconH3 size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
</div>
{/* List formatting group */}
<div className={groupClass}>
<Tooltip text={t('editor.tooltips.bullet_list')}>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={
editor.isActive('bulletList') ? activeButtonClass : buttonClass
}
>
<IconList size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.numbered_list')}>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={
editor.isActive('orderedList') ? activeButtonClass : buttonClass
}
>
<IconListNumbers size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.blockquote')}>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={
editor.isActive('blockquote') ? activeButtonClass : buttonClass
}
>
<IconQuote size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.code_block')}>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={
editor.isActive('codeBlock') ? activeButtonClass : buttonClass
}
>
<IconBrandCodesandbox
size={18}
stroke={1.5}
className="text-gray-300"
/>
</button>
</Tooltip>
</div>
{/* History controls */}
<div className={groupClass}>
<Tooltip text={t('editor.tooltips.undo')}>
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
className={`${buttonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<IconArrowBackUp size={18} stroke={1.5} className="text-gray-300" />
</button>
</Tooltip>
<Tooltip text={t('editor.tooltips.redo')}>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
className={`${buttonClass} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<IconArrowForwardUp
size={18}
stroke={1.5}
className="text-gray-300"
/>
</button>
</Tooltip>
</div>
</div>
</div>
{/* Link Modal */}
<LinkModal
isOpen={linkModalOpen}
onClose={() => setLinkModalOpen(false)}
onSubmit={handleLinkSubmit}
initialUrl={editor?.getAttributes('link').href || ''}
/>
{/* Password Modal */}
<PasswordModal
isOpen={passwordModalOpen}
onClose={() => setPasswordModalOpen(false)}
onSubmit={handlePasswordSubmit}
/>
</>
);
};
const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }),
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
validate: (href) => /^https?:\/\//.test(href),
}),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
},
orderedList: {
keepMarks: true,
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
},
}),
];
export default function Editor({ content = '', setContent, editable = true, ...props }) {
return (
<div className="prose prose-sm max-w-none border border-gray-700 rounded-md p-2 h-full flex flex-col">
<EditorProvider
slotBefore={editable ? <MenuBar content={content} /> : <ReadOnlyMenuBar />}
extensions={extensions}
editable={editable}
content={content}
onUpdate={({ editor }) => {
if (setContent) {
setContent(editor.getHTML());
}
}}
editorProps={{
attributes: {
class: 'flex-1 overflow-auto focus:outline-none text-gray-100 prose-headings:mt-6 prose-headings:first:mt-0 prose-headings:text-gray-100 prose-h1:text-2xl prose-h1:font-bold prose-h1:mb-4 prose-h2:text-xl prose-h2:font-bold prose-h2:mb-3 prose-h3:text-lg prose-h3:font-semibold prose-h3:mb-3 prose-p:my-3 prose-p:leading-relaxed prose-p:text-gray-200 prose-strong:text-gray-200 prose-strong:font-bold prose-em:text-gray-200 prose-ul:pl-5 prose-ul:my-3 prose-ol:pl-5 prose-ol:my-3 prose-li:my-1 prose-li:leading-normal prose-li:text-gray-200 prose-a:text-gray-100 prose-a:underline prose-a:font-medium hover:prose-a:text-gray-50 prose-code:bg-gray-800 prose-code:text-gray-200 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono prose-pre:bg-gray-900 prose-pre:text-white prose-pre:p-4 prose-pre:rounded-lg prose-pre:my-4 prose-pre:overflow-auto prose-pre:code:bg-transparent prose-pre:code:p-0 prose-pre:code:text-sm prose-pre:code:font-mono prose-blockquote:border-l-4 prose-blockquote:border-gray-600 prose-blockquote:pl-4 prose-blockquote:py-1 prose-blockquote:my-4 prose-blockquote:italic prose-blockquote:text-gray-300 prose-hr:my-6 prose-hr:border-gray-700',
},
}}
{...props}
/>
</div>
);
}

View File

@ -1,133 +0,0 @@
import { IconKey } from '@tabler/icons';
import { generate } from 'generate-password-browser';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
// https://github.com/zenoamaro/react-quill/issues/836#issuecomment-2440290893
class ReactQuillFixed extends ReactQuill {
destroyEditor() {
super.destroyEditor();
delete this.editor;
}
}
// Custom button component for password generation
const PasswordButton = ({ onClick, tooltip }) => (
<button
type="button"
className="ql-customButton p-1 hover:bg-gray-700 rounded-md transition-colors"
onClick={onClick}
title={tooltip}
>
<IconKey size={18} className="text-gray-400" />
</button>
);
const Quill = ({ value, onChange, readOnly, defaultValue }) => {
const quillRef = useRef(null);
const containerRef = useRef(null);
const { t } = useTranslation();
useEffect(() => {
const handleClickOutside = (event) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target) &&
quillRef.current
) {
const editor = quillRef.current.getEditor();
if (editor) {
editor.blur();
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Custom handler for password generation
const handlePasswordGeneration = () => {
if (!quillRef.current) return;
const editor = quillRef.current.getEditor();
if (!editor) return;
const password = generate({
length: 16,
numbers: true,
symbols: true,
uppercase: true,
lowercase: true,
});
const range = editor.getSelection(true);
editor.insertText(range.index, password + '\n');
};
// Define modules based on readOnly state
const modules = readOnly
? {
toolbar: false, // Disable toolbar in read-only mode
}
: {
toolbar: {
container: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'code-block'],
['clean'],
// Custom button container
[{ 'custom-button': 'password' }],
],
handlers: {
'custom-button': handlePasswordGeneration,
},
},
};
return (
<div
ref={containerRef}
className="bg-gray-800 border border-gray-700 rounded-md overflow-hidden"
>
<div className="relative">
<ReactQuillFixed
ref={quillRef}
value={value || ''}
onChange={onChange}
readOnly={readOnly}
placeholder={defaultValue}
theme={readOnly ? 'bubble' : 'snow'}
modules={modules}
className="bg-gray-800 text-gray-100 placeholder-gray-500 [&_.ql-editor]:min-h-[200px] [&_.ql-editor]:text-base [&_.ql-editor]:font-sans [&_.ql-editor]:leading-relaxed
[&_.ql-toolbar]:border-gray-700 [&_.ql-toolbar]:bg-gray-900
[&_.ql-container]:border-gray-700
[&_.ql-editor_h1]:text-3xl [&_.ql-editor_h1]:font-bold [&_.ql-editor_h1]:mb-4
[&_.ql-editor_h2]:text-2xl [&_.ql-editor_h2]:font-bold [&_.ql-editor_h2]:mb-3
[&_.ql-editor_h3]:text-xl [&_.ql-editor_h3]:font-bold [&_.ql-editor_h3]:mb-2
[&_.ql-editor_p]:mb-4
[&_.ql-editor_ul]:list-disc [&_.ql-editor_ul]:ml-4
[&_.ql-editor_ol]:list-decimal [&_.ql-editor_ol]:ml-4
[&_.ql-snow_.ql-toolbar_button]:text-gray-300
[&_.ql-snow_.ql-toolbar_button:hover]:text-white"
/>
{!readOnly && (
<div className="absolute top-0 right-0 transform -translate-x-1/2 h-[42px] flex items-center">
<div className="w-[1px] h-5 bg-gray-700 mx-4"></div>
<PasswordButton
onClick={handlePasswordGeneration}
tooltip={t('home.inject_password')}
/>
</div>
)}
</div>
</div>
);
};
export default Quill;

View File

@ -1,4 +1,4 @@
export function Switch({ checked, onChange, className = '', children }) {
export function Switch({ checked, onChange, className = '', children, ...props }) {
return (
<button
type="button"
@ -8,6 +8,7 @@ export function Switch({ checked, onChange, className = '', children }) {
className={`${
checked ? 'bg-primary' : 'bg-gray-700'
} relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-gray-900 ${className}`}
{...props}
>
<span className="sr-only">{children}</span>
<span

View File

@ -165,10 +165,6 @@ html {
--margin-basic: 10px;
}
.ql-editor {
min-height: 200px;
}
@layer base {
html {
@apply font-sans bg-gray-900 text-gray-100;
@ -188,104 +184,3 @@ html {
@apply btn bg-red-500 text-white hover:bg-red-600;
}
}
/* Quill Editor Styles */
.ql-toolbar {
border-color: rgb(55, 65, 81) !important; /* border-gray-700 */
background-color: rgb(31, 41, 55) !important; /* bg-gray-800 */
}
.ql-container {
border-color: rgb(55, 65, 81) !important; /* border-gray-700 */
background-color: rgb(31, 41, 55) !important; /* bg-gray-800 */
}
.ql-editor {
color: rgb(243, 244, 246) !important; /* text-gray-100 */
min-height: 150px;
}
.ql-editor.ql-blank::before {
color: rgb(107, 114, 128) !important; /* text-gray-500 */
}
/* Toolbar buttons - Updated colors */
.ql-snow.ql-toolbar button,
.ql-snow .ql-toolbar button {
color: rgb(209, 213, 219) !important; /* text-gray-300 */
}
.ql-snow.ql-toolbar button:hover,
.ql-snow .ql-toolbar button:hover {
color: rgb(243, 244, 246) !important; /* text-gray-100 */
}
.ql-snow.ql-toolbar button.ql-active,
.ql-snow .ql-toolbar button.ql-active {
color: rgb(255, 255, 255) !important; /* text-white */
background-color: rgb(75, 85, 99) !important; /* bg-gray-600 */
}
/* Toolbar icons - Updated colors */
.ql-snow .ql-stroke {
stroke: rgb(209, 213, 219) !important; /* text-gray-300 */
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow .ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow .ql-toolbar button.ql-active .ql-stroke {
stroke: rgb(255, 255, 255) !important; /* text-white */
}
.ql-snow .ql-fill {
fill: rgb(209, 213, 219) !important; /* text-gray-300 */
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-fill {
fill: rgb(255, 255, 255) !important; /* text-white */
}
/* Dropdown menus */
.ql-snow .ql-picker {
color: rgb(209, 213, 219) !important; /* text-gray-300 */
}
.ql-snow .ql-picker-options {
background-color: rgb(31, 41, 55) !important; /* bg-gray-800 */
border-color: rgb(55, 65, 81) !important; /* border-gray-700 */
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label,
.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: rgb(55, 65, 81) !important; /* border-gray-700 */
}
.ql-snow .ql-picker-label:hover,
.ql-snow .ql-picker-item:hover,
.ql-snow .ql-picker-item.ql-selected {
color: rgb(255, 255, 255) !important; /* text-white */
}
/* Links in editor */
.ql-editor a {
color: rgb(209, 213, 219) !important; /* text-gray-300 */
}
/* Quill Bubble Theme Styles for Read-Only Mode */
.ql-bubble .ql-editor {
padding: 1rem !important;
min-height: 100px !important;
background-color: rgb(31, 41, 55) !important; /* bg-gray-800 */
color: rgb(243, 244, 246) !important; /* text-gray-100 */
}
/* Remove default bubble theme tooltips */
.ql-bubble .ql-tooltip {
display: none !important;
}
.ql-snow .ql-picker-label.ql-active {
color: inherit !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,6 @@ import {
IconFile,
IconHeading,
IconLock,
IconPerspective,
IconShieldLock,
IconSquarePlus,
} from '@tabler/icons';
@ -15,8 +14,8 @@ import { Link, useLocation, useParams } from 'react-router-dom';
import { decrypt } from '../../../shared/helpers/crypto';
import { getSecret, secretExists } from '../../api/secret';
import { downloadFile } from '../../api/upload';
import Editor from '../../components/editor';
import ErrorBox from '../../components/error-box';
import Quill from '../../components/quill';
const getEncryptionKeyHash = (hash) => {
const id = '#encryption_key=';
@ -42,7 +41,6 @@ const Secret = () => {
const [files, setFiles] = useState(null);
const [isDownloaded, setIsDownloaded] = useState([]);
const [error, setError] = useState(null);
const [hasConvertedBase64ToPlain, setHasConvertedBase64ToPlain] = useState(false);
// Fetch secret existence on mount
useEffect(() => {
@ -142,15 +140,6 @@ const Secret = () => {
}
};
const convertBase64ToPlain = () => {
if (!hasConvertedBase64ToPlain) {
setSecret(btoa(secret));
} else {
setSecret(atob(secret));
}
setHasConvertedBase64ToPlain(!hasConvertedBase64ToPlain);
};
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="space-y-6">
@ -176,7 +165,7 @@ const Secret = () => {
{/* Main Content */}
<div
className={`space-y-6 p-6 rounded-lg transition-all duration-200 ${
className={`space-y-6 md:p-6 rounded-lg transition-all duration-200 ${
!isSecretOpen
? 'bg-gray-800/30 border-2 border-gray-700/50 shadow-lg relative'
: 'bg-gray-800/50'
@ -268,7 +257,7 @@ const Secret = () => {
{/* Secret Content */}
<div className="w-full">
<Quill value={secret} secretId={secretId} readOnly />
<Editor content={secret} editable={false} />
</div>
{/* File Downloads Section */}
@ -321,20 +310,6 @@ const Secret = () => {
</button>
)}
{/* Convert Base64 Button */}
{isSecretOpen && (
<button
onClick={convertBase64ToPlain}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 text-gray-300
hover:bg-gray-700 hover:text-white rounded-md transition-colors"
>
<IconPerspective size={14} />
{!hasConvertedBase64ToPlain
? t('secret.convert_b64')
: t('secret.convert_utf8')}
</button>
)}
{/* Create New Secret Button */}
{isSecretOpen && (
<Link

1350
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,6 @@
"@fastify/vite": "^7.0.1",
"@prisma/client": "^4.12.0",
"@vitejs/plugin-react": "^4.2.1",
"tailwindcss": "^3.4.14",
"bcryptjs": "^2.4.3",
"config": "^3.3.6",
"email-validator": "^2.0.4",
@ -62,18 +61,22 @@
"pretty-bytes": "^4.0.2",
"recharts": "^2.13.0",
"sanitize-filename": "^1.6.3",
"tailwindcss": "^3.4.14",
"unfetch": "^4.2.0",
"validator": "^13.7.0",
"y8": "^1.0.5"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"react-qr-code": "^2.0.8",
"@tabler/icons": "^1.83.1",
"@tailwindcss/forms": "^0.5.9",
"tweetnacl": "^0.14.5",
"tweetnacl-util": "^0.15.1",
"react-router-dom": "^6.21.3",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"autoprefixer": "^10.4.20",
"buffer": "^6.0.3",
"classcat": "^5.0.3",
"dayjs": "^1.11.7",
@ -95,7 +98,10 @@
"react": "^18.3.1",
"react-dom": "^18.0.0",
"react-i18next": "^11.18.5",
"react-quill": "^2.0.0",
"react-qr-code": "^2.0.8",
"react-router-dom": "^6.21.3",
"tweetnacl": "^0.14.5",
"tweetnacl-util": "^0.15.1",
"vite": "^5.4.10",
"zustand": "^5.0.0"
},

View File

@ -274,5 +274,55 @@
"not_logged_in": "Nejste přihlášeni",
"something_went_wrong": "Něco se pokazilo!",
"loading": "Načítání",
"common": {}
"common": {},
"editor": {
"tooltips": {
"bold": "Tučné",
"italic": "Kurzíva",
"strikethrough": "Přeškrtnuté",
"inline_code": "Inline kód",
"link": "Odkaz",
"remove_link": "Odstranit odkaz",
"insert_password": "Vložit heslo",
"paragraph": "Odstavec",
"heading1": "Nadpis 1",
"heading2": "Nadpis 2",
"heading3": "Nadpis 3",
"bullet_list": "Odrážkový seznam",
"numbered_list": "Číslovaný seznam",
"blockquote": "Citace",
"code_block": "Blok kódu",
"undo": "Zpět",
"redo": "Znovu",
"copy_html": "Kopírovat jako HTML",
"copy_text": "Kopírovat jako prostý text",
"copy_base64": "Kopírovat jako Base64"
},
"link_modal": {
"title": "Vložit odkaz",
"url_label": "URL",
"url_placeholder": "https://priklad.cz",
"cancel": "Zrušit",
"insert": "Vložit",
"update": "Aktualizovat"
},
"password_modal": {
"title": "Vložit heslo",
"length_label": "Délka hesla",
"options_label": "Možnosti hesla",
"include_numbers": "Zahrnout čísla",
"include_symbols": "Zahrnout symboly",
"include_uppercase": "Zahrnout velká písmena",
"include_lowercase": "Zahrnout malá písmena",
"generated_password": "Vygenerované heslo",
"refresh": "Obnovit",
"cancel": "Zrušit",
"insert": "Vložit"
},
"copy_success": {
"html": "HTML zkopírováno!",
"text": "Prostý text zkopírován!",
"base64": "Base64 zkopírováno!"
}
}
}

View File

@ -259,5 +259,55 @@
"password_protected": "Passwortgeschützt",
"ip_restricted": "IP-beschränkt",
"average_max_views": "Durchschnittlich erlaubte Aufrufe pro Geheimnis"
},
"editor": {
"tooltips": {
"bold": "Fett",
"italic": "Kursiv",
"strikethrough": "Durchgestrichen",
"inline_code": "Inline-Code",
"link": "Link",
"remove_link": "Link entfernen",
"insert_password": "Passwort einfügen",
"paragraph": "Absatz",
"heading1": "Überschrift 1",
"heading2": "Überschrift 2",
"heading3": "Überschrift 3",
"bullet_list": "Aufzählungsliste",
"numbered_list": "Nummerierte Liste",
"blockquote": "Zitat",
"code_block": "Codeblock",
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"copy_html": "Als HTML kopieren",
"copy_text": "Als Klartext kopieren",
"copy_base64": "Als Base64 kopieren"
},
"link_modal": {
"title": "Link einfügen",
"url_label": "URL",
"url_placeholder": "https://beispiel.de",
"cancel": "Abbrechen",
"insert": "Einfügen",
"update": "Aktualisieren"
},
"password_modal": {
"title": "Passwort einfügen",
"length_label": "Passwortlänge",
"options_label": "Passwortoptionen",
"include_numbers": "Zahlen einschließen",
"include_symbols": "Symbole einschließen",
"include_uppercase": "Großbuchstaben einschließen",
"include_lowercase": "Kleinbuchstaben einschließen",
"generated_password": "Generiertes Passwort",
"refresh": "Aktualisieren",
"cancel": "Abbrechen",
"insert": "Einfügen"
},
"copy_success": {
"html": "HTML kopiert!",
"text": "Klartext kopiert!",
"base64": "Base64 kopiert!"
}
}
}

View File

@ -275,5 +275,55 @@
"password_protected": "Password Protected",
"ip_restricted": "IP Restricted",
"average_max_views": "Average Max Views Per Secret"
},
"editor": {
"tooltips": {
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"inline_code": "Inline Code",
"link": "Link",
"remove_link": "Remove Link",
"insert_password": "Insert Password",
"paragraph": "Paragraph",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"bullet_list": "Bullet List",
"numbered_list": "Numbered List",
"blockquote": "Blockquote",
"code_block": "Code Block",
"undo": "Undo",
"redo": "Redo",
"copy_html": "Copy as HTML",
"copy_text": "Copy as Plain Text",
"copy_base64": "Copy as Base64"
},
"link_modal": {
"title": "Insert Link",
"url_label": "URL",
"url_placeholder": "https://example.com",
"cancel": "Cancel",
"insert": "Insert",
"update": "Update"
},
"password_modal": {
"title": "Insert Password",
"length_label": "Password Length",
"options_label": "Password Options",
"include_numbers": "Include Numbers",
"include_symbols": "Include Symbols",
"include_uppercase": "Include Uppercase",
"include_lowercase": "Include Lowercase",
"generated_password": "Generated Password",
"refresh": "Refresh",
"cancel": "Cancel",
"insert": "Insert"
},
"copy_success": {
"html": "HTML copied!",
"text": "Plain text copied!",
"base64": "Base64 copied!"
}
}
}

View File

@ -276,5 +276,55 @@
"password_protected": "Protegidos con contraseña",
"ip_restricted": "Restringidos por IP",
"average_max_views": "Promedio de vistas máximas por secreto"
},
"editor": {
"tooltips": {
"bold": "Negrita",
"italic": "Cursiva",
"strikethrough": "Tachado",
"inline_code": "Código en línea",
"link": "Enlace",
"remove_link": "Eliminar enlace",
"insert_password": "Insertar contraseña",
"paragraph": "Párrafo",
"heading1": "Encabezado 1",
"heading2": "Encabezado 2",
"heading3": "Encabezado 3",
"bullet_list": "Lista con viñetas",
"numbered_list": "Lista numerada",
"blockquote": "Cita",
"code_block": "Bloque de código",
"undo": "Deshacer",
"redo": "Rehacer",
"copy_html": "Copiar como HTML",
"copy_text": "Copiar como texto plano",
"copy_base64": "Copiar como Base64"
},
"link_modal": {
"title": "Insertar enlace",
"url_label": "URL",
"url_placeholder": "https://ejemplo.com",
"cancel": "Cancelar",
"insert": "Insertar",
"update": "Actualizar"
},
"password_modal": {
"title": "Insertar contraseña",
"length_label": "Longitud de la contraseña",
"options_label": "Opciones de contraseña",
"include_numbers": "Incluir números",
"include_symbols": "Incluir símbolos",
"include_uppercase": "Incluir mayúsculas",
"include_lowercase": "Incluir minúsculas",
"generated_password": "Contraseña generada",
"refresh": "Actualizar",
"cancel": "Cancelar",
"insert": "Insertar"
},
"copy_success": {
"html": "¡HTML copiado!",
"text": "¡Texto plano copiado!",
"base64": "¡Base64 copiado!"
}
}
}

View File

@ -3,7 +3,7 @@
"sign_out": "Se déconnecter",
"sign_up": "S'enregistrer",
"not_logged_in": "Non connecté",
"something_went_wrong": "Quelque chose s'est mal passé !",
"something_went_wrong": "Quelque chose s'est mal passé !",
"loading": "Chargement",
"common": {},
"account": {
@ -16,7 +16,7 @@
"upload_files": "Téléversement de fichiers",
"expiration": "Délai d'expiration de secret de 14 ou 28 jours",
"secrets": "Lister et supprimer vos secrets",
"more": "Merci d'avoir rejoint Hemmelig.app !",
"more": "Merci d'avoir rejoint Hemmelig.app !",
"features": "Fonctionnalités"
},
"account": {
@ -24,7 +24,7 @@
"can_not_update_profile": "Impossible de mettre à jour votre compte utilisateur",
"can_not_delete": "Impossible de supprimer l'utilisateur",
"delete_account": "Supprimer votre compte",
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer votre compte ?",
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer votre compte ?",
"dont_delete_account": "Non, ne pas le supprimer",
"email": "Email",
"your_password": "Mot de passe actuel",
@ -44,13 +44,13 @@
"no": "Non",
"delete_secret": "Supprimer ce secret",
"dont_delete_secret": "Non, ne pas le supprimer",
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer ce secret ?"
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer ce secret ?"
},
"settings": {
"read_only_mode": "Mode lecture seule",
"readonly_only_for_non_admin": "L'instance Hemmelig doit-elle être en lecture seule pour les utilisateurs non administrateurs ou créateurs ?",
"readonly_only_for_non_admin": "L'instance Hemmelig doit-elle être en lecture seule pour les utilisateurs non administrateurs ou créateurs ?",
"disable_users": "Désactiver les utilisateurs",
"disable_signin": "Désactiver la connexion des utilisateurs ?",
"disable_signin": "Désactiver la connexion des utilisateurs ?",
"disable_user_account_creation": "Désactiver la création de nouveaux comptes",
"disable_user_account_creation_description": "Empêcher la création de compte par tout le monde. Un administrateur pourra toujours créer de nouveaux comptes utilisateurs",
"hide_allowed_ip_input": "Désactiver les restrictions d'IPs",
@ -69,7 +69,7 @@
"creator": "Créateur",
"user": "Utilisateur",
"delete": "Suppression",
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?",
"do_you_want_delete": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?",
"delete_user": "Supprimer cet utilisateur",
"dont_delete_user": "Non, ne pas le supprimer",
"have_to_be_admin": "Vous devez être un administrateur pour voir les utilisateurs"
@ -87,14 +87,14 @@
},
"session": {
"auth": "Authentification",
"expire": "Votre session va expirer. Voulez-vous la renouveler ?",
"expire": "Votre session va expirer. Voulez-vous la renouveler ?",
"update": "Renouveler la session"
}
},
"home": {
"app_subtitle": "Collez un mot de passe, un message confidentiel ou des données privées.",
"welcome": "Assurez-vous que vos données sensibles restent chiffrées, sécurisées et confidentielles.",
"bummer": "C'est dommage !",
"bummer": "C'est dommage !",
"maintxtarea": "Écrivez vos informations sensibles",
"file_upload": "Téléverser des fichiers",
"content_title": "Titre",
@ -156,7 +156,7 @@
"one_time_use": "Ce secret ne peut être vu qu'une seule fois et s'autodétruira après avoir été lu.",
"norwegian_meaning": "signifie « secret » en norvégien",
"copy": "Copier dans le presse-papiers",
"copied": "Copié !",
"copied": "Copié !",
"create": "Créer",
"sign_in": "Connexion",
"complete_url": "URL secrète complète",
@ -174,7 +174,7 @@
"secret": {
"view_your_secret": "Afficher votre secret",
"will_show_once": "Par défaut, le secret ne sera affiché qu'une seule fois.",
"views_left": "Affichages restants :",
"views_left": "Affichages restants :",
"password_required": "Un mot de passe est nécessaire pour ouvrir ce secret",
"view_secret": "Voir le secret",
"create_secret": "Créer un nouveau secret",
@ -240,7 +240,7 @@
},
"not_found": {
"title": "Page introuvable",
"description": "Oups ! Il semblerait que ce secret se soit évaporé. Ne vous inquiétez pas toutefois, vous pouvez en créer un nouveau ou consulter la documentation.",
"description": "Oups ! Il semblerait que ce secret se soit évaporé. Ne vous inquiétez pas toutefois, vous pouvez en créer un nouveau ou consulter la documentation.",
"go_home": "Créer un nouveau secret",
"view_docs": "Voir la doc de l'API"
},
@ -258,5 +258,55 @@
"average_views": "Nombre moyen de vues",
"views_per_secret": "Vues par secret",
"description": "Un aperçu complet de l'utilisation de votre plateforme et de ses fonctionnalités de sécurité"
},
"editor": {
"tooltips": {
"bold": "Gras",
"italic": "Italique",
"strikethrough": "Barré",
"inline_code": "Code en ligne",
"link": "Lien",
"remove_link": "Supprimer le lien",
"insert_password": "Insérer un mot de passe",
"paragraph": "Paragraphe",
"heading1": "Titre 1",
"heading2": "Titre 2",
"heading3": "Titre 3",
"bullet_list": "Liste à puces",
"numbered_list": "Liste numérotée",
"blockquote": "Citation",
"code_block": "Bloc de code",
"undo": "Annuler",
"redo": "Rétablir",
"copy_html": "Copier en HTML",
"copy_text": "Copier en texte brut",
"copy_base64": "Copier en Base64"
},
"link_modal": {
"title": "Insérer un lien",
"url_label": "URL",
"url_placeholder": "https://exemple.com",
"cancel": "Annuler",
"insert": "Insérer",
"update": "Mettre à jour"
},
"password_modal": {
"title": "Insérer un mot de passe",
"length_label": "Longueur du mot de passe",
"options_label": "Options du mot de passe",
"include_numbers": "Inclure des chiffres",
"include_symbols": "Inclure des symboles",
"include_uppercase": "Inclure des majuscules",
"include_lowercase": "Inclure des minuscules",
"generated_password": "Mot de passe généré",
"refresh": "Actualiser",
"cancel": "Annuler",
"insert": "Insérer"
},
"copy_success": {
"html": "HTML copié !",
"text": "Texte brut copié !",
"base64": "Base64 copié !"
}
}
}

View File

@ -260,5 +260,55 @@
"password_protected": "Protetti da password",
"ip_restricted": "Limitati per IP",
"average_max_views": "Media visualizzazioni massime per segreto"
},
"editor": {
"tooltips": {
"bold": "Grassetto",
"italic": "Corsivo",
"strikethrough": "Barrato",
"inline_code": "Codice in linea",
"link": "Collegamento",
"remove_link": "Rimuovi collegamento",
"insert_password": "Inserisci password",
"paragraph": "Paragrafo",
"heading1": "Titolo 1",
"heading2": "Titolo 2",
"heading3": "Titolo 3",
"bullet_list": "Elenco puntato",
"numbered_list": "Elenco numerato",
"blockquote": "Citazione",
"code_block": "Blocco di codice",
"undo": "Annulla",
"redo": "Ripristina",
"copy_html": "Copia come HTML",
"copy_text": "Copia come testo semplice",
"copy_base64": "Copia come Base64"
},
"link_modal": {
"title": "Inserisci collegamento",
"url_label": "URL",
"url_placeholder": "https://esempio.it",
"cancel": "Annulla",
"insert": "Inserisci",
"update": "Aggiorna"
},
"password_modal": {
"title": "Inserisci password",
"length_label": "Lunghezza password",
"options_label": "Opzioni password",
"include_numbers": "Includi numeri",
"include_symbols": "Includi simboli",
"include_uppercase": "Includi maiuscole",
"include_lowercase": "Includi minuscole",
"generated_password": "Password generata",
"refresh": "Aggiorna",
"cancel": "Annulla",
"insert": "Inserisci"
},
"copy_success": {
"html": "HTML copiato!",
"text": "Testo semplice copiato!",
"base64": "Base64 copiato!"
}
}
}

View File

@ -275,5 +275,55 @@
"password_protected": "Met wachtwoord",
"ip_restricted": "Beperkt op IP",
"average_max_views": "Gemiddelde voor max. aantal weergaven"
},
"editor": {
"tooltips": {
"bold": "Vet",
"italic": "Cursief",
"strikethrough": "Doorhalen",
"inline_code": "Inline code",
"link": "Link",
"remove_link": "Link verwijderen",
"insert_password": "Wachtwoord invoegen",
"paragraph": "Paragraaf",
"heading1": "Kop 1",
"heading2": "Kop 2",
"heading3": "Kop 3",
"bullet_list": "Opsommingstekens",
"numbered_list": "Genummerde lijst",
"blockquote": "Citaat",
"code_block": "Codeblok",
"undo": "Ongedaan maken",
"redo": "Opnieuw",
"copy_html": "Kopiëren als HTML",
"copy_text": "Kopiëren als platte tekst",
"copy_base64": "Kopiëren als Base64"
},
"link_modal": {
"title": "Link invoegen",
"url_label": "URL",
"url_placeholder": "https://voorbeeld.nl",
"cancel": "Annuleren",
"insert": "Invoegen",
"update": "Bijwerken"
},
"password_modal": {
"title": "Wachtwoord invoegen",
"length_label": "Wachtwoordlengte",
"options_label": "Wachtwoordopties",
"include_numbers": "Cijfers opnemen",
"include_symbols": "Symbolen opnemen",
"include_uppercase": "Hoofdletters opnemen",
"include_lowercase": "Kleine letters opnemen",
"generated_password": "Gegenereerd wachtwoord",
"refresh": "Vernieuwen",
"cancel": "Annuleren",
"insert": "Invoegen"
},
"copy_success": {
"html": "HTML gekopieerd!",
"text": "Platte tekst gekopieerd!",
"base64": "Base64 gekopieerd!"
}
}
}

View File

@ -264,5 +264,55 @@
"password_protected": "Protegidos por palavra-passe",
"ip_restricted": "Restritos por IP",
"average_max_views": "Média de visualizações máximas por segredo"
},
"editor": {
"tooltips": {
"bold": "Negrito",
"italic": "Itálico",
"strikethrough": "Riscado",
"inline_code": "Código em linha",
"link": "Ligação",
"remove_link": "Remover ligação",
"insert_password": "Inserir palavra-passe",
"paragraph": "Parágrafo",
"heading1": "Título 1",
"heading2": "Título 2",
"heading3": "Título 3",
"bullet_list": "Lista com marcadores",
"numbered_list": "Lista numerada",
"blockquote": "Citação",
"code_block": "Bloco de código",
"undo": "Desfazer",
"redo": "Refazer",
"copy_html": "Copiar como HTML",
"copy_text": "Copiar como texto simples",
"copy_base64": "Copiar como Base64"
},
"link_modal": {
"title": "Inserir ligação",
"url_label": "URL",
"url_placeholder": "https://exemplo.pt",
"cancel": "Cancelar",
"insert": "Inserir",
"update": "Atualizar"
},
"password_modal": {
"title": "Inserir palavra-passe",
"length_label": "Comprimento da palavra-passe",
"options_label": "Opções de palavra-passe",
"include_numbers": "Incluir números",
"include_symbols": "Incluir símbolos",
"include_uppercase": "Incluir maiúsculas",
"include_lowercase": "Incluir minúsculas",
"generated_password": "Palavra-passe gerada",
"refresh": "Atualizar",
"cancel": "Cancelar",
"insert": "Inserir"
},
"copy_success": {
"html": "HTML copiado!",
"text": "Texto simples copiado!",
"base64": "Base64 copiado!"
}
}
}

View File

@ -274,5 +274,55 @@
"password_protected": "Zaščiteno z geslom",
"ip_restricted": "Omejeno z IP",
"average_max_views": "Povprečno največje število ogledov na skrivnost"
},
"editor": {
"tooltips": {
"bold": "Krepko",
"italic": "Ležeče",
"strikethrough": "Prečrtano",
"inline_code": "Vrstična koda",
"link": "Povezava",
"remove_link": "Odstrani povezavo",
"insert_password": "Vstavi geslo",
"paragraph": "Odstavek",
"heading1": "Naslov 1",
"heading2": "Naslov 2",
"heading3": "Naslov 3",
"bullet_list": "Seznam z oznakami",
"numbered_list": "Oštevilčen seznam",
"blockquote": "Citat",
"code_block": "Blok kode",
"undo": "Razveljavi",
"redo": "Ponovi",
"copy_html": "Kopiraj kot HTML",
"copy_text": "Kopiraj kot navadno besedilo",
"copy_base64": "Kopiraj kot Base64"
},
"link_modal": {
"title": "Vstavi povezavo",
"url_label": "URL",
"url_placeholder": "https://primer.si",
"cancel": "Prekliči",
"insert": "Vstavi",
"update": "Posodobi"
},
"password_modal": {
"title": "Vstavi geslo",
"length_label": "Dolžina gesla",
"options_label": "Možnosti gesla",
"include_numbers": "Vključi številke",
"include_symbols": "Vključi simbole",
"include_uppercase": "Vključi velike črke",
"include_lowercase": "Vključi male črke",
"generated_password": "Generirano geslo",
"refresh": "Osveži",
"cancel": "Prekliči",
"insert": "Vstavi"
},
"copy_success": {
"html": "HTML kopiran!",
"text": "Navadno besedilo kopirano!",
"base64": "Base64 kopiran!"
}
}
}

View File

@ -273,5 +273,55 @@
"password_protected": "密码保护",
"ip_restricted": "IP限制",
"average_max_views": "每个秘密的平均最大查看次数"
},
"editor": {
"tooltips": {
"bold": "粗体",
"italic": "斜体",
"strikethrough": "删除线",
"inline_code": "内联代码",
"link": "链接",
"remove_link": "移除链接",
"insert_password": "插入密码",
"paragraph": "段落",
"heading1": "标题1",
"heading2": "标题2",
"heading3": "标题3",
"bullet_list": "无序列表",
"numbered_list": "有序列表",
"blockquote": "引用",
"code_block": "代码块",
"undo": "撤销",
"redo": "重做",
"copy_html": "复制为HTML",
"copy_text": "复制为纯文本",
"copy_base64": "复制为Base64"
},
"link_modal": {
"title": "插入链接",
"url_label": "URL",
"url_placeholder": "https://example.com",
"cancel": "取消",
"insert": "插入",
"update": "更新"
},
"password_modal": {
"title": "插入密码",
"length_label": "密码长度",
"options_label": "密码选项",
"include_numbers": "包含数字",
"include_symbols": "包含符号",
"include_uppercase": "包含大写字母",
"include_lowercase": "包含小写字母",
"generated_password": "生成的密码",
"refresh": "刷新",
"cancel": "取消",
"insert": "插入"
},
"copy_success": {
"html": "HTML已复制",
"text": "纯文本已复制!",
"base64": "Base64已复制"
}
}
}

View File

@ -192,5 +192,55 @@
"password_protected": "密碼保護",
"ip_restricted": "IP 限制",
"average_max_views": "每個機密的平均最大瀏覽次數"
},
"editor": {
"tooltips": {
"bold": "粗體",
"italic": "斜體",
"strikethrough": "刪除線",
"inline_code": "行內程式碼",
"link": "連結",
"remove_link": "移除連結",
"insert_password": "插入密碼",
"paragraph": "段落",
"heading1": "標題1",
"heading2": "標題2",
"heading3": "標題3",
"bullet_list": "項目符號列表",
"numbered_list": "編號列表",
"blockquote": "引用",
"code_block": "程式碼區塊",
"undo": "復原",
"redo": "重做",
"copy_html": "複製為HTML",
"copy_text": "複製為純文字",
"copy_base64": "複製為Base64"
},
"link_modal": {
"title": "插入連結",
"url_label": "URL",
"url_placeholder": "https://example.com",
"cancel": "取消",
"insert": "插入",
"update": "更新"
},
"password_modal": {
"title": "插入密碼",
"length_label": "密碼長度",
"options_label": "密碼選項",
"include_numbers": "包含數字",
"include_symbols": "包含符號",
"include_uppercase": "包含大寫字母",
"include_lowercase": "包含小寫字母",
"generated_password": "產生的密碼",
"refresh": "重新整理",
"cancel": "取消",
"insert": "插入"
},
"copy_success": {
"html": "HTML已複製",
"text": "純文字已複製!",
"base64": "Base64已複製"
}
}
}

View File

@ -2,7 +2,7 @@ const colors = require('tailwindcss/colors');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./client/**/*.{js,jsx}', , './client/**/*.html'],
content: ['./client/**/*.{js,jsx}', './client/**/*.html'],
darkMode: 'class',
theme: {
extend: {
@ -67,5 +67,5 @@ module.exports = {
},
},
},
plugins: [require('@tailwindcss/forms')],
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
};