feat: wordlists user interface

This commit is contained in:
2025-08-14 14:38:46 -06:00
parent 8481a897dc
commit b4e802fdec
25 changed files with 744 additions and 8 deletions

12
src/Settings.tsx Normal file
View File

@@ -0,0 +1,12 @@
import Details from './components/ui/Details'
import WordLists from './components/WordLists'
const Settings = () => {
return (
<Details title="Randomness Helpers">
<WordLists />
</Details>
)
}
export default Settings

View File

@@ -0,0 +1,131 @@
import Button from '@/components/ui/Button'
import UploadButton from '@/components/ui/UploadButton'
import '@/components/wordLists.css'
import {
type ChangeEventHandler,
useCallback,
useEffect,
useState
} from 'react'
import { useShallow } from 'zustand/react/shallow'
import Select from '@/components/ui/Select'
import EditWordList from '@/components/wordList/EditWordList'
import { useStore } from '@/store'
const WordLists = () => {
const wordLists = useStore(
useShallow((state) => Object.keys(state.wordLists))
)
const addWordList = useStore((state) => state.addWordList)
const removeWordList = useStore((state) => state.removeWordList)
const [selectedWordList, setSelectedWordList] = useState('')
const handleUpload: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const file = event.target.files?.[0]
if (file !== undefined && file.type === 'text/plain') {
const reader = new FileReader()
reader.onload = (e) => {
const fileName = file.name.replace('.txt', '')
if (wordLists.includes(fileName)) {
alert(`A word list with the name ${fileName} already exists`)
return
}
const text = e.target?.result
if (typeof text === 'string') {
const words = text.split('\n')
addWordList(fileName, words)
}
}
reader.readAsText(file)
}
},
[addWordList, wordLists]
)
useEffect(() => {
const selectedExist = wordLists.includes(selectedWordList)
if (!selectedExist) {
setSelectedWordList('')
}
}, [wordLists, selectedWordList])
return (
<>
<strong>Word Lists</strong>
<div id="st-rnd-wordlist-controls">
<Select
id="st-rnd-wordlist-select"
disabled={wordLists.length === 0}
value={selectedWordList}
onChange={(event) => {
const value = event.target.value
if (value !== '') {
setSelectedWordList(value)
}
}}
>
<option value="" disabled>
Select a word list
</option>
{wordLists.map((name) => (
<option key={name}>{name}</option>
))}
</Select>
<EditWordList currentWordList={selectedWordList} />
<Button
type="button"
icon
disabled={selectedWordList === ''}
onClick={() => {
if (selectedWordList !== '') {
const wordList = useStore.getState().wordLists[selectedWordList]
if (wordList === undefined) {
return
}
const blob = new Blob([wordList.join('\n')], {
type: 'text/plain'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${selectedWordList}.txt`
link.click()
URL.revokeObjectURL(url)
}
}}
>
<i className="fa fa-fw fa-download"></i>
</Button>
<Button
type="button"
icon
disabled={selectedWordList === ''}
onClick={() => {
if (selectedWordList !== '') {
removeWordList(selectedWordList)
setSelectedWordList('')
}
}}
>
<i className="fa fa-fw fa-trash"></i>
</Button>
</div>
<p className="st-rnd-small-text">
Wordlists are txt files with one (or more) word per line, are used in
placeholders and macros.
</p>
<div id="st-rnd-upload-wordlist-container">
<UploadButton
id="upload-wordlist"
onChange={handleUpload}
accept=".txt"
/>
</div>
</>
)
}
export default WordLists

View File

@@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, DetailedHTMLProps, FC } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/button.css'
export type ButtonProps = MergeOmitting<
DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>,
{
neutral?: boolean
icon?: boolean
}
>
const Button: FC<ButtonProps> = ({
children,
className,
neutral,
icon,
...buttonProps
}) => {
return (
<button
className={`st-rnd-button${neutral === true ? ' button-neutral' : ''}${icon === true ? ' button-icon' : ''}${
className ? ` ${className}` : ''
}`}
{...buttonProps}
>
{children}
</button>
)
}
export default Button

View File

@@ -0,0 +1,24 @@
import '@/components/ui/details.css'
import type { FC, ReactNode } from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type DetailsProps = MergeOmitting<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLDetailsElement>,
HTMLDetailsElement
>,
{
title: string
children: ReactNode
}
>
const Details: FC<DetailsProps> = ({ title, className, children }) => {
return (
<details className={`st-rnd-details${className ? ` ${className}` : ''}`}>
<summary>{title}</summary>
<div>{children}</div>
</details>
)
}
export default Details

View File

@@ -0,0 +1,28 @@
import '@/components/ui/dialog.css'
import type {
DetailedHTMLProps,
DialogHTMLAttributes,
FC,
ReactNode
} from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type DialogProps = MergeOmitting<
DetailedHTMLProps<DialogHTMLAttributes<HTMLDialogElement>, HTMLDialogElement>,
{
children: ReactNode
}
>
const Dialog: FC<DialogProps> = ({ children, className, ...dialogProps }) => {
return (
<dialog
className={`st-rnd-dialog${className ? ` ${className}` : ''}`}
{...dialogProps}
>
{children}
</dialog>
)
}
export default Dialog

View File

@@ -0,0 +1,19 @@
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/input.css'
export type InputProps = MergeOmitting<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
{ fullWidth?: boolean }
>
const Input: FC<InputProps> = ({ className, ...inputProps }) => {
return (
<input
className={`st-rnd-input${className ? ` ${className}` : ''}`}
{...inputProps}
/>
)
}
export default Input

View File

@@ -0,0 +1,28 @@
import type {
DetailedHTMLProps,
FC,
ReactNode,
SelectHTMLAttributes
} from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/select.css'
type SelectProps = MergeOmitting<
DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>,
{
children: ReactNode
}
>
const Select: FC<SelectProps> = ({ children, className, ...selectProps }) => {
return (
<select
className={`st-rnd-select${className ? ` ${className}` : ''}`}
{...selectProps}
>
{children}
</select>
)
}
export default Select

View File

@@ -0,0 +1,24 @@
import type { DetailedHTMLProps, FC, TextareaHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
import '@/components/ui/textarea.css'
export type TextareaProps = MergeOmitting<
DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>,
{
className?: string
}
>
const Textarea: FC<TextareaProps> = ({ className, ...textareaProps }) => {
return (
<textarea
className={`st-rnd-textarea${className ? ` ${className}` : ''}`}
{...textareaProps}
/>
)
}
export default Textarea

View File

@@ -0,0 +1,23 @@
import '@/components/ui/button.css'
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
import type { MergeOmitting } from '@/types/helpers'
export type UploadButtonProps = MergeOmitting<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>,
{
id: string
label?: string
}
>
const UploadButton: FC<UploadButtonProps> = ({ id, label, ...inputProps }) => {
return (
<>
<input type="file" id={id} {...inputProps} />
<label htmlFor={id} className={`st-rnd-button button-neutral`}>
{label || 'Upload'}
</label>
</>
)
}
export default UploadButton

View File

@@ -0,0 +1,81 @@
.st-rnd-button {
--btn-background-color: var(--color-primary-3);
--btn-background-color-hover: var(--color-primary-4);
--btn-background-color-active: var(--color-primary-5);
--btn-border-color: var(--color-primary-6);
--btn-color: var(--color-primary-12);
&.button-neutral {
--btn-background-color: var(--color-neutral-3);
--btn-background-color-hover: var(--color-neutral-4);
--btn-background-color-active: var(--color-neutral-5);
--btn-border-color: var(--color-neutral-6);
--btn-color: var(--color-neutral-12);
}
}
.st-rnd-button {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--btn-border-color);
background-color: var(--btn-background-color);
color: var(--btn-color);
font-weight: 600;
text-align: center;
cursor: pointer;
&:hover {
background-color: var(--btn-background-color-hover);
}
&:active {
background-color: var(--btn-background-color-active);
}
&:disabled {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-background-color) 40%
);
border-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-border-color) 40%
);
color: color-mix(in srgb, var(--color-neutral-1) 60%, var(--btn-color) 40%);
cursor: not-allowed;
&:hover {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-background-color) 40%
);
border-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-border-color) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--btn-color) 40%
);
}
}
&.button-icon {
padding: 4px;
height: auto;
width: auto ;
aspect-ratio: 1;
font-size: 0.8em;
}
}

View File

@@ -0,0 +1,29 @@
.st-rnd-details {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
&[open] {
summary {
background-color: var(--color-neutral-5);
}
}
> summary {
padding: 4px 8px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
line-height: 20px;
&:hover {
background-color: var(--color-neutral-4);
}
}
> div {
padding: 8px;
}
}

View File

@@ -0,0 +1,15 @@
.st-rnd-dialog {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 8px;
background-color: var(--color-neutral-1);
color: var(--color-neutral-12);
&::backdrop {
background-color: color-mix(
in srgb,
var(--color-neutral-2) 80%,
transparent 20%
);
}
}

View File

@@ -0,0 +1,10 @@
.st-rnd-input {
display: block;
width: 100%;
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 4px 8px;
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
}

View File

@@ -0,0 +1,38 @@
.st-rnd-select {
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
padding: 4px 8px;
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
&:active {
background-color: var(--color-neutral-12);
}
&:disabled {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-3) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-12) 40%
);
cursor: not-allowed;
&:active {
background-color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-3) 40%
);
color: color-mix(
in srgb,
var(--color-neutral-1) 60%,
var(--color-neutral-12) 40%
);
}
}
}

View File

@@ -0,0 +1,9 @@
.st-rnd-textarea {
display: block;
width: 100%;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--color-neutral-6);
background-color: var(--color-neutral-3);
color: var(--color-neutral-12);
}

View File

@@ -0,0 +1,114 @@
import { type FC, useRef, useState } from 'react'
import Button from '@/components/ui/Button'
import Dialog from '@/components/ui/Dialog'
import { useStore } from '@/store'
import '@/components/wordList/editWordlList.css'
import Input from '../ui/Input'
import Textarea from '../ui/Textarea'
export interface RenameWordListProps {
currentWordList: string
}
const EditWordList: FC<RenameWordListProps> = ({ currentWordList }) => {
const [internalStatus, setInternalStatus] = useState<string | undefined>()
const [newName, setNewName] = useState<string | undefined>(undefined)
const dialogRef = useRef<HTMLDialogElement>(null)
return (
<>
<Button
type="button"
icon
disabled={currentWordList === ''}
onClick={() => {
if (dialogRef.current === null || currentWordList === '') {
return
}
const wordList = useStore.getState().wordLists[currentWordList]
if (wordList === undefined) {
return
}
setNewName(currentWordList)
setInternalStatus(wordList.join('\n'))
dialogRef.current.showModal()
}}
>
<i className="fa fa-fw fa-pencil"></i>
</Button>
<Dialog id="st-rnd-wordlist-edit-dialog" ref={dialogRef}>
<div>
<h2 id="st-rnd-wordlist-edit-dialog-title">
Edit Word List: <span>{currentWordList}</span>
</h2>
<label htmlFor="st-rnd-wordlist-name">Name</label>
<p className="st-rnd-small-text">
Name of the word list, used in placeholder and macros.
</p>
<Input
id="st-rnd-wordlist-name"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<label htmlFor="st-rnd-wordlist-words">Words</label>
<p className="st-rnd-small-text">
Words separated by newlines, words can be more than one word!
</p>
<Textarea
id="st-rnd-wordlist-words"
value={internalStatus}
onChange={(e) => setInternalStatus(e.target.value)}
rows={10}
/>
<div id="st-rnd-wordlist-actions">
<Button
type="button"
neutral
onClick={() => {
if (dialogRef.current === null) {
return
}
dialogRef.current.close()
setInternalStatus(undefined)
setNewName(undefined)
}}
>
Close
</Button>
<Button
type="button"
onClick={() => {
if (
currentWordList === '' ||
newName === undefined ||
internalStatus === undefined ||
dialogRef.current === null
) {
return
}
if (newName === '') {
alert('Name cannot be empty')
return
}
const addWordList = useStore.getState().addWordList
if (currentWordList !== newName) {
const removeWordList = useStore.getState().removeWordList
removeWordList(currentWordList)
}
addWordList(newName, internalStatus.split('\n'))
dialogRef.current.close()
setInternalStatus(undefined)
setNewName(undefined)
}}
>
Save
</Button>
</div>
</div>
</Dialog>
</>
)
}
export default EditWordList

View File

@@ -0,0 +1,24 @@
#st-rnd-wordlist-edit-dialog {
position: relative;
width: clamp(400px, 100%, 1200px);
& > div {
display: flex;
flex-direction: column;
gap: 8px;
}
#st-rnd-wordlist-edit-dialog-title {
width: 100%;
text-align: center;
& > span {
color: var(--color-primary-9);
}
}
#st-rnd-wordlist-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}

View File

@@ -0,0 +1,21 @@
#st-rnd-upload-wordlist-container {
display: flex;
flex-direction: column;
}
#st-rnd-wordlist-controls {
display: flex;
flex-direction: row;
gap: 8px;
& > select {
flex-grow: 1;
}
& > button {
flex-shrink: 0;
}
}
.st-rnd-small-text {
font-size: 0.8em;
color: var(--color-neutral-11);
}

27
src/gui.css Normal file
View File

@@ -0,0 +1,27 @@
:root {
--color-neutral-1: #111113;
--color-neutral-2: #18191b;
--color-neutral-3: #212225;
--color-neutral-4: #272a2d;
--color-neutral-5: #2e3135;
--color-neutral-6: #363a3f;
--color-neutral-7: #43484e;
--color-neutral-8: #5a6169;
--color-neutral-9: #696e77;
--color-neutral-10: #777b84;
--color-neutral-11: #b0b4ba;
--color-neutral-12: #edeef0;
--color-primary-1: #13131e;
--color-primary-2: #171625;
--color-primary-3: #202248;
--color-primary-4: #262a65;
--color-primary-5: #303374;
--color-primary-6: #3d3e82;
--color-primary-7: #4a4a95;
--color-primary-8: #5958b1;
--color-primary-9: #5b5bd6;
--color-primary-10: #6e6ade;
--color-primary-11: #b1a9ff;
--color-primary-12: #e0dffe;
}

24
src/gui.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import Settings from '@/Settings'
import '@/gui.css'
const renderSettingsGui = () => {
const rootContainer = document.getElementById('extensions_settings')
if (rootContainer === null) {
throw new Error('[st-randomness-helpers] root container not found')
}
const rootElement = document.createElement('div')
rootContainer.appendChild(rootElement)
ReactDOM.createRoot(rootElement).render(
<StrictMode>
<Settings />
</StrictMode>
)
}
export default renderSettingsGui

View File

@@ -1,4 +1,3 @@
console.log('Hello via Bun!')
import renderSettingsGui from '@/gui'
const context = SillyTavern.getContext()
console.log(context)
renderSettingsGui()

5
src/types/helpers.ts Normal file
View File

@@ -0,0 +1,5 @@
export type MergeOmitting<ReplaceableType, ReplacerType> = Omit<
ReplaceableType,
keyof ReplacerType
> &
ReplacerType