added themes, updated title and meta for seo and bump to v4.0

This commit is contained in:
ph1r3754r73r
2026-06-13 17:06:14 -07:00
parent 384876a83b
commit dc379434bb
27 changed files with 5054 additions and 1155 deletions
+19 -4
View File
@@ -13,6 +13,7 @@ P4RS3LT0NGV3/
├── index.template.html # HTML shell; tool *script* tags updated by inject-tool-scripts
├── css/
│ ├── style.css
│ ├── themes-atmosphere.css # Optional per-theme atmosphere / component overrides
│ └── notification.css
├── js/
│ ├── app.js # Vue app entry
@@ -37,7 +38,8 @@ P4RS3LT0NGV3/
│ │ ├── glitchTokens.js
│ │ ├── history.js
│ │ ├── notifications.js
│ │ ── theme.js
│ │ ── openrouterModels.js # Fetch/cache OpenRouter model lists for Settings UI
│ │ └── theme.js # Theme registry + applyTheme / cycleTheme
│ └── tools/ # One *Tool.js per tab (extends Tool.js)
│ ├── Tool.js
│ ├── AntiClassifierTool.js
@@ -98,7 +100,8 @@ P4RS3LT0NGV3/
├── docs/
│ ├── TOOL-SYSTEM.md
│ ├── TOOL_ARCHITECTURE.md
── UI-COMPONENTS.md
── UI-COMPONENTS.md
│ └── THEMES.md # Adding / editing themes
├── README.md
└── CONTRIBUTING.md
@@ -118,7 +121,7 @@ dist/ # npm run build — gitignored
- **`js/core/`** — Shared business logic and infrastructure (not tab-specific)
- Examples: `decoder.js` (DecodeTool, decoder pipeline), `steganography.js` (EmojiTool, steg engine), `toolRegistry.js` (registers tools, merges Vue surface), `transformOptions.js` (shared transform UI helpers)
- **`js/utils/`** — Cross-cutting helpers (`clipboard`, `EmojiUtils` in `emoji.js`, notifications, theme, etc.)
- **`js/utils/`** — Cross-cutting helpers (`clipboard`, `EmojiUtils` in `emoji.js`, notifications, `theme.js`, `openrouterModels.js`, etc.)
- **`js/data/`** — Committed static payloads (models, prompts, glitch token data, end sequences, `emojiCompatibility.js`). **`emojiData.js`** is **not** edited here — it is **generated** to `dist/js/data/emojiData.js` by `npm run build:emoji`.
- **`src/`** — `emojiWordMap.js` feeds the emoji build; `transformers/` holds transformer modules
- **Generated bundle** — `npm run build:transforms` writes `dist/js/bundles/transforms-bundle.js` (a legacy `js/bundles/transforms-bundle.js` path may exist for older workflows and is gitignored)
@@ -313,7 +316,18 @@ Utilities are shared helper functions used across the app. Currently, utility fu
- Document with JSDoc comments
- Consider adding to existing modules if functionality is related
**Note:** Prefer `js/utils/` for shared helpers (clipboard, emoji, escapeParser, focus, glitchTokens, history, notifications, theme). Use `js/config/` for constants.
**Note:** Prefer `js/utils/` for shared helpers (clipboard, emoji, escapeParser, focus, glitchTokens, history, notifications, theme, openrouterModels). Use `js/config/` for constants.
### 4. Adding or Updating a Theme
Themes are registry entries plus CSS token blocks—not Vue components.
1. Register the theme in `js/utils/theme.js` (`themes` array).
2. Add a `body.theme-<id> { … }` token block in `css/style.css` (copy from `body.theme-dark` or an existing custom theme).
3. Optionally add atmosphere rules in `css/themes-atmosphere.css`.
4. `npm run build`, then verify in the browser (dropdown, **`D`** cycle, nav, utility dock, toggles, mobile header utility button).
Full step-by-step guide, token checklist, and testing list: **[docs/THEMES.md](docs/THEMES.md)**.
## 🧪 Testing
@@ -417,6 +431,7 @@ This:
- **Tool system**: `docs/TOOL-SYSTEM.md` — Templates, injection, UI vocabulary
- **Tool architecture**: `docs/TOOL_ARCHITECTURE.md`
- **UI components**: `docs/UI-COMPONENTS.md`
- **Themes**: `docs/THEMES.md` — Adding and editing themes
## ✅ Checklist Before Submitting
+44 -11
View File
@@ -1,6 +1,8 @@
# 🐍 P4RS3LT0NGV3 - Universal Text Translator
# 🐍 P4RS3LT0NGV3 4.0 — Universal Text Translator
A powerful web-based text transformation and steganography tool with **223** built-in text transforms spanning encodings, classical and modern ciphers, Unicode styles, formatting, and niche alphabets. Think of it as a universal translator for ALL alphabets and writing systems!
A powerful web-based text transformation and steganography tool with **223** built-in text transforms spanning encodings, classical and modern ciphers, Unicode styles, formatting, and niche alphabets. Think of it as a universal translator for ALL alphabets and writing systems!
**Version 4.0** brings a redesigned desktop app shell, seven themes (including WCAG 2.1 AA **Accessible**), mobile utility panels, OpenRouter model curation, and responsive UI polish across all tools.
The app is a **static site**: run **`npm run build`** (after `npm install`), then open **`dist/index.html`** in your browser—no local server required. **Alternatively**, you can run it as a local app over HTTP with **`npm start`** or **`npx serve dist -l 8080`** (see [Getting Started](#getting-started) below). Core transforms, decoder, and steganography work **without** calling the cloud; features that use [OpenRouter](https://openrouter.ai/) need **network access** and an API key (see below).
@@ -286,7 +288,7 @@ Tabs appear in **UI order** below. **OpenRouter** (optional or required per tool
- **Real-time**: Updates as you type.
- **Script & language hints**: Unicode script ranges and Latin word-marker heuristics for common languages.
- **AI translate to English** (optional, OpenRouter): When text looks foreign, optional one-shot translate to English.
- **Keyboard shortcut**: **D**.
- **Deep link**: `#decoder` in the URL opens this tab directly.
### 😀 **Emoji** (Steganography)
@@ -340,6 +342,18 @@ Tabs appear in **UI order** below. **OpenRouter** (optional or required per tool
- **Dictionary mode**: Seeded random gibberish over a configurable character set.
- **Removal mode**: Random or **specific** letter removal with batch **variations** and min/max strip lengths.
### 📷 **Codes** (QR & barcodes)
*Tab id: `codes` — deep link: `#codes` (generate) or `#codes/decode` (scan).*
- **Generate**: QR codes and common 1D barcodes from any text or numeric payload.
- **Formats**: **QR Code** (PNG download), **Code 128**, **EAN-13**, and **Code 39** (SVG download).
- **QR options**: Pixel size, quiet-zone margin, and error-correction level (L / M / Q / H).
- **Barcode options**: Bar height, module width, and optional human-readable label under the bars.
- **Decode**: Upload a PNG, JPEG, GIF, or WebP image to read QR codes and barcodes — **fully client-side** (image never leaves the browser).
- **Copy & reuse**: Send decoded text to the Generate tab with one click.
- **Offline-friendly**: QR/barcode libraries (`qrcode`, JsBarcode, ZXing) are bundled into `js/vendor/` at build time — no runtime CDN for this tab.
### 🪄 **PromptCraft** (via OpenRouter)
- **9 Mutation Strategies**: Rephrase, Obfuscate, Role-Play Wrap, Multi-Language, Expand, Compress, Metaphor, Fragment, and Custom
@@ -355,13 +369,18 @@ models
- **Same key**: Uses the same OpenRouter API key as Translation and PromptCraft.
### 📱 **User Experience**
- **Dark/Light Theme**: Toggle between themes
- **Themes**: **Advanced Settings** → Theme (Dark, Light, **Accessible**, BT6, Pliny, Cyberpunk, Wild West) — press **`D`** to cycle; choice saved in the browser
- **Desktop layout**: Left tool nav, main workspace, right utility dock (Copy History, Glitch Tokens, End Sequences, Advanced Settings)
- **Mobile / narrow screens**: Tool picker dropdown; utility panels slide in from the right (open via the **columns** icon in the header)
- **Copy History**: Track all copied content with timestamps
- **Auto-copy**: Automatically copy transformed text
- **Keyboard Shortcuts**: Quick access to features
- **Auto-copy**: Automatically copy transformed text (where enabled per tool)
- **Keyboard Shortcuts**: Quick access to features (including **`D`** for theme cycle)
- **Responsive Design**: Works on all device sizes
- **Accessibility**: Screen reader friendly with proper ARIA labels
- **Side panels**: Glitch token browser (optional data), end-sequence / delimiter strings for research, and **Advanced Settings** (OpenRouter key, steganography tuning)
- **Side panels**: Glitch token browser (optional data), end-sequence / delimiter strings for research, and **Advanced Settings** (OpenRouter API key, model curation, steganography tuning)
- **Deep links**: Open a specific tool tab via URL hash — e.g. `#decoder`, `#steganography`, `#codes`, `#codes/decode` (browser back/forward supported)
Contributors: see **[docs/THEMES.md](docs/THEMES.md)** for how to add or edit themes.
### 🔑 **OpenRouter API Key Setup**
@@ -369,9 +388,10 @@ models
1. Create an account at [openrouter.ai](https://openrouter.ai/)
2. Generate an API key (starts with `sk-or-...`)
3. In P4RS3LT0NGV3, click the **sliders icon** (top-right) to open **Advanced Settings**
3. In P4RS3LT0NGV3, open **Advanced Settings** from the utility dock (desktop) or the **columns** icon in the header (mobile)
4. Paste your key and click **Save Key**
5. Your key is stored locally in your browser only — never sent anywhere except OpenRouter
5. Choose which OpenRouter models appear in Translation, PromptCraft, Anti-Classifier, and related dropdowns
6. Your key is stored locally in your browser only — never sent anywhere except OpenRouter
> **Tip:** Some models (like Gemma 3) are free on OpenRouter. Frontier models (Claude, GPT, Gemini Pro) require credits.
@@ -411,11 +431,12 @@ Notes:
npm install
# Build all assets (required before use). Order matches package.json:
# build:tools → build:copy → build:index → build:transforms → build:emoji → build:templates
# build:tools → build:codes-vendor → build:copy → build:index → build:transforms → build:emoji → build:templates
npm run build
# Or build individual components:
npm run build:tools # Auto-discover tools, inject script tags into dist/index.html
npm run build:codes-vendor # Bundle qrcode, JsBarcode, ZXing → dist/js/vendor/
npm run build:copy # Copy static files to dist/
npm run build:index # Generate src/transformers/index.js (ES module index)
npm run build:transforms # Bundle all transformers to dist/js/bundles/transforms-bundle.js
@@ -457,6 +478,7 @@ npm run preview # npm run build, then serve dist/
- Transformers are bundled from `src/transformers/` to `dist/js/bundles/transforms-bundle.js`
- Tool templates are injected from `templates/` into `dist/index.html`
- Emoji data is generated to `dist/js/data/`
- QR/barcode vendors (`qrcode`, `JsBarcode`, `@zxing/library`) are bundled to `dist/js/vendor/` via `npm run build:codes-vendor`
### **Browser Support**
- Chrome/Edge 80+
@@ -469,6 +491,15 @@ npm run preview # npm run build, then serve dist/
- **Memory Efficient**: Streams large text without loading into memory
- **Optimized Rendering**: Efficient DOM updates with Vue.js
## 🔧 **What's New in 4.0**
- 🆕 **Version 4.0 release**: Major UI refresh — desktop three-column layout, theme system, and mobile utility dock
- 🆕 **Theme system**: Seven themes (Dark, Light, **Accessible**, BT6, Pliny, Cyberpunk, Wild West) with token-based CSS and atmosphere layers
- 🆕 **Desktop app shell**: Left tool nav, main workspace, right utility dock (Copy History, Glitch Tokens, End Sequences, Settings)
- 🆕 **Mobile utility panels**: Slide-over panels with responsive tab bar (no horizontal scroll)
- 🆕 **OpenRouter model settings**: Curate which models appear in AI tool dropdowns (Settings)
- 🆕 **Responsive tool polish**: Bijection, Tokenade, transform cards, and topbar layout improvements
## 🔧 **Recent Fixes & Improvements**
### **Fixed Issues**
@@ -478,6 +509,8 @@ npm run preview # npm run build, then serve dist/
-**Reverse Functions**: Added missing reverse functions for many transforms
### **New Features**
- 🆕 **Codes tool**: Generate QR codes and barcodes (Code 128, EAN-13, Code 39); decode from uploaded images client-side
- 🆕 **URL deep links**: Jump to any tool tab with `#tab` hashes (e.g. `#codes/decode`)
- 🆕 **AI Translation**: Translate to 20+ languages (including dead/exotic) via OpenRouter using TranslateGemma prompt format
- 🆕 **PromptCraft Tool**: AI-powered prompt mutation with 9 strategies and 48+ models
- 🆕 **OpenRouter Integration**: Unified API key management for all AI-powered features
@@ -541,4 +574,4 @@ This project is open source. See LICENSE file for details.
---
**P4RS3LT0NGV3** - Because sometimes you need to speak in tongues that don't exist! 🐉✨
**P4RS3LT0NGV3 4.0** - Because sometimes you need to speak in tongues that don't exist! 🐉✨
+6 -5
View File
@@ -57,19 +57,20 @@ let indexContent = fs.readFileSync(templatePath, 'utf8');
indexContent = indexContent.replace(/\r\n/g, '\n');
// Find the tool-content-container
const startMarker = '<div id="tool-content-container">';
const endMarker = '</div>\n\n </div>\n\n <!-- Copy History Panel -->';
const startMarker = '<div id="tool-content-container"';
const endMarker = '</div>\n </main>';
const startIndex = indexContent.indexOf(startMarker);
const endIndex = indexContent.indexOf(endMarker);
const startTagEnd = indexContent.indexOf('>', startIndex);
const endIndex = indexContent.indexOf(endMarker, startTagEnd);
if (startIndex === -1 || endIndex === -1) {
if (startIndex === -1 || startTagEnd === -1 || endIndex === -1) {
console.error('\n❌ Could not find tool content container markers');
process.exit(1);
}
// Build the replacement content
const before = indexContent.substring(0, startIndex + startMarker.length);
const before = indexContent.substring(0, startTagEnd + 1);
const after = indexContent.substring(endIndex);
const replacement = `
+5 -4
View File
@@ -3,11 +3,12 @@
position: fixed;
bottom: 20px;
right: 20px;
background-color: #25282c;
color: #00FF41;
background-color: var(--secondary-bg);
color: var(--success-color);
padding: 10px 20px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
border: 1px solid var(--input-border);
box-shadow: var(--panel-shadow-soft);
z-index: 1000;
display: flex;
align-items: center;
@@ -16,7 +17,7 @@
}
.copy-notification.error {
color: #ff4141;
color: #e53935;
}
.copy-notification.fade-out {
+2089 -534
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
# Themes
P4RS3LT0NGV3 uses a **token-based theme system**. Most UI reads CSS custom properties (`--accent-color`, `--button-bg`, etc.) from `body.theme-*` blocks, so new themes are mostly palette + typography work—not per-component rewrites.
## Built-in themes
| ID | Name | Notes |
|----|------|--------|
| `dark` | Dark | Default; blue accent |
| `light` | Light | Light surfaces; blue accent |
| `accessible` | Accessible | WCAG 2.1 AA high-contrast; system fonts; strong focus rings |
| `bt6` | BT6 | Black + blood red + gold; mono titles |
| `pliny` | Pliny | CRT green-on-black |
| `cyberpunk` | Cyberpunk | Magenta + cyan neon |
| `wildwest` | Wild West | Cream / sage / dusty rose (light `color-scheme`) |
Users pick a theme in **Advanced Settings** (utility dock → Settings tab). Press **`D`** to cycle themes. The choice is saved in `localStorage` under the key `theme`.
## Accessible theme
The **`accessible`** theme targets **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** for color, contrast, focus, and motion.
| Requirement | Implementation |
|-------------|------------------|
| **1.4.3 Contrast (AA)** | `#000000` / `#595959` text on `#ffffff`; `#004080` accent; category active colors darkened for white labels |
| **1.4.11 Non-text contrast** | `2px` borders at `#555555` on inputs, buttons, and cards |
| **2.4.7 Focus visible** | `3px` `#004080` outline + `2px` offset on all interactive elements |
| **1.4.12 Text spacing** | `1rem` base size, `1.5` line-height, system UI fonts |
| **1.4.1 Use of color** | Active nav/tabs use underline + weight + border, not color alone |
| **2.3.3 Animation** | Logo glitch and decorative card effects disabled |
| **Transform cards** | Text **Options** / **Favorite** buttons; fixed preview contrast and row heights |
| **Translation picker** | Text **Favorite** (and **Remove** on custom langs); star/× icons hidden |
Decorative themes (Cyberpunk, Pliny, etc.) are unchanged. For audits, test with the **Accessible** theme selected and keyboard-only navigation.
## How it works
1. **`js/utils/theme.js`** — Registry (`themes` array), `applyTheme()`, `cycleTheme()`, persistence.
2. **`css/style.css`** — One `body.theme-<id> { … }` block per theme defining design tokens.
3. **`css/themes-atmosphere.css`** — Optional “premium” overrides for custom themes (nav, transform cards, backgrounds, fonts). Imported at the top of `style.css`.
4. **`index.template.html`** — Inline bootstrap script applies the saved theme **before** Vue mounts (avoids flash of wrong theme).
When a theme is applied, the body gets:
- `theme-<id>` — primary selector (e.g. `theme-bt6`)
- `dark-theme` or `light-theme` — legacy aliases still used by a few rules
Copy an existing `body.theme-*` block as a starting point—`body.theme-dark` and `body.theme-light` are the simplest references; custom themes often follow the BT6 / Pliny pattern.
## Adding a new theme
### 1. Register in `js/utils/theme.js`
```javascript
{ id: 'mytheme', name: 'My Theme', icon: 'fa-star' }
```
- **`id`** — lowercase, no spaces; becomes `body.theme-mytheme`.
- **`name`** — label in the dropdown.
- **`icon`** — optional Font Awesome class (not shown in dropdown today, but kept for future use).
### 2. Add tokens in `css/style.css`
Add a new block (copy from `body.theme-dark` or an existing custom theme):
```css
body.theme-mytheme {
--main-bg-color: #…;
--secondary-bg: #…;
--nav-bg: #…;
--utility-tab-bg: #…;
--text-color: #…;
--text-muted: #…;
--accent-color: #…;
--accent-color-rgb: R, G, B; /* comma-separated, no spaces */
--accent-hover: #…;
/* … accent-tint-*, surface-*, button-*, input-*, error-*, etc. */
--glitch-color: #…;
--glitch-color-rgb: R, G, B;
--switch-border: #…;
--switch-surface-glow: rgba(…);
--switch-track-bg: #…;
--switch-track-border: #…;
--switch-track-checked-bg: #…;
--switch-track-checked-glow: rgba(…);
--switch-thumb-bg: #…;
--switch-thumb-glow: rgba(…);
--switch-thumb-checked-bg: #…;
--switch-thumb-checked-glow: rgba(…);
color-scheme: dark; /* or light for pale themes */
}
```
**Required tokens** — At minimum, match what `body.theme-dark` defines: surfaces, text, accent (+ `-rgb`), buttons, inputs, focus/error/success, tooltips, switch tokens, and legacy aliases (`--text-primary`, `--border-color`, etc.) at the bottom of the block.
**Light themes** — Set `color-scheme: light` and ensure `--text-on-accent` contrasts on `--accent-color`. Wild West is the reference for a light custom theme.
**Optional theme tokens** (used by atmosphere CSS):
- `--theme-display-font`, `--theme-ui-font`, `--theme-mono-font`
- `--theme-secondary`, `--theme-secondary-rgb`
- `--theme-radius` — corner radius (`0` for sharp, `2px``6px` for rounded)
- Wild West also uses `--theme-cream`, `--theme-charcoal`, etc.
### 3. Optional atmosphere in `css/themes-atmosphere.css`
For a distinctive look beyond color swaps, add a section:
```css
body.theme-mytheme .app-root::before { /* background texture / tint */ }
body.theme-mytheme .transform-button { /* card chrome */ }
body.theme-mytheme .app-nav .tab-buttons button.active { /* nav active state */ }
```
If you add a custom theme here, also extend the shared selectors at the top of the file (`body[class*="theme-…"]`) so shell layering (`::before` / z-index) stays consistent.
**Style guidelines** (project convention):
- Use **flat colors** only—no CSS gradients unless explicitly requested.
- Toggle switches (`.switch.neon`) pick up colors from `--switch-*` tokens automatically.
- Avoid logo `::before` / `::after` decorations.
### 4. Fonts (if needed)
Google Fonts are imported at the top of `css/style.css`. Add families there, then reference them in your `--theme-*-font` tokens.
### 5. Select carets & dropdowns
Custom themes with non-default accents often need a `select` caret override. See existing blocks:
```css
body.theme-mytheme select:not(.settings-theme-select) { background-image: url("data:…"); }
body.theme-mytheme .settings-theme-select { … }
```
Wild West required extra care so theme dropdown carets do not tile—use `background-repeat: no-repeat` and longhand `background-*` when overriding selects.
### 6. Build & test
```bash
npm run build
npm test
```
Open `dist/index.html` (or `npm start`) and check:
- [ ] Advanced Settings theme dropdown lists your theme
- [ ] **`D`** cycles through it without errors
- [ ] Nav, main content, utility dock (desktop)
- [ ] Mobile: utility panel toggle in header; no overlapping FAB
- [ ] Transform grid, toggles, inputs, notifications
- [ ] Advanced Settings / OpenRouter model dropdown (if applicable)
- [ ] Reload persists choice
No change to `index.template.html` is required unless you rename the bootstrap script flow—the dropdown is driven by `themeOptions` from Vue, which reads `ThemeUtils.getThemes()`.
## File checklist
| File | Action |
|------|--------|
| `js/utils/theme.js` | Add `{ id, name, icon }` |
| `css/style.css` | Add `body.theme-<id> { … }` token block |
| `css/themes-atmosphere.css` | Optional component/atmosphere overrides |
| `css/style.css` `@import` | Already imports `themes-atmosphere.css` — no change |
| `README.md` | Add theme to user-facing list (optional) |
## Updating an existing theme
1. Edit the `body.theme-<id>` token block in `css/style.css`.
2. Adjust matching rules in `css/themes-atmosphere.css` if the theme has premium styling.
3. Rebuild and spot-check the same checklist above.
Token changes propagate everywhere that uses `var(--…)`; you rarely need to touch individual tools or templates.
## Related docs
- **User-facing overview**: `README.md` → User Experience
- **Project layout**: `CONTRIBUTING.md` → Project Structure
- **UI patterns**: `docs/UI-COMPONENTS.md`
+356 -273
View File
@@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parseltongue 2.0 - LLM Payload Crafter</title>
<title>Parseltongue 4.0 — Text Encoder, Decoder &amp; Steganography Tool</title>
<meta name="description" content="Free online text encoder &amp; decoder with 223+ transforms: ciphers, Base64, Unicode styles, emoji steganography &amp; QR codes. No install required — because sometimes you need to speak in tongues that don't exist.">
<meta name="keywords" content="P4RS3LT0NGV3, Parseltongue, text encoder, text decoder, cipher tool, steganography, Unicode converter, Base64">
<meta name="application-name" content="P4RS3LT0NGV3">
<meta property="og:title" content="Parseltongue 4.0 — Text Encoder, Decoder &amp; Steganography Tool">
<meta property="og:description" content="223+ encodings, ciphers &amp; steganography in your browser. Because sometimes you need to speak in tongues that don't exist.">
<meta property="og:type" content="website">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/notification.css">
@@ -13,161 +19,185 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
<div id="app" class="container">
<header>
<div class="logo">
<h1>🐉️︎︎︎︎︎︎︎️︎︎︎️︎︎︎️︎️️ P4RS3LT0NGV3</h1>
<script>
(function () {
try {
var saved = localStorage.getItem('theme') || 'dark';
document.body.classList.add('theme-' + saved);
document.body.classList.add(
saved === 'light' || saved === 'accessible' ? 'light-theme' : 'dark-theme'
);
} catch (e) {
document.body.classList.add('theme-dark', 'dark-theme');
}
})();
</script>
<div id="app" class="app-root">
<header class="app-topbar">
<div class="logo app-logo">
<h1>🐉 P4RS3LT0NGV3</h1>
</div>
<div class="actions">
<div class="actions topbar-actions">
<button
@click="toggleCopyHistory"
class="history-button"
title="Show copy history"
aria-label="Show copy history"
type="button"
class="topbar-utility-btn"
@click="mobileUtilityOpen ? closeMobileUtility() : openMobileUtility()"
:title="mobileUtilityOpen ? 'Close panels' : 'Open panels'"
:aria-label="mobileUtilityOpen ? 'Close panels' : 'Open panels'"
:aria-expanded="mobileUtilityOpen"
>
<i class="fas fa-history"></i>
<i :class="mobileUtilityOpen ? 'fas fa-xmark' : 'fas fa-columns'"></i>
</button>
<button
@click="toggleTheme"
@keyup.d="toggleTheme"
class="theme-button"
title="Toggle dark mode (D)"
aria-label="Toggle dark mode"
>
<i class="fas" :class="isDarkTheme ? 'fa-moon' : 'fa-sun'"></i>
</button>
<a
href="https://github.com/elder-plinius/P4RS3LT0NGV3"
target="_blank"
class="github-button"
<a
href="https://github.com/elder-plinius/P4RS3LT0NGV3"
target="_blank"
class="github-button"
title="View source on GitHub"
aria-label="View source code on GitHub"
>
<i class="fab fa-github"></i>
</a>
<button
@click="toggleUnicodePanel"
class="history-button"
title="Advanced Settings"
aria-label="Advanced Settings"
>
<i class="fas fa-sliders-h"></i>
</button>
<button
@click="toggleGlitchTokenPanel"
class="history-button"
title="Glitch Tokens"
aria-label="Glitch Tokens"
>
<i class="fas fa-bug"></i>
</button>
<button
@click="toggleEndSequencePanel"
class="history-button"
title="End sequences (delimiter strings)"
aria-label="End sequences"
>
<i class="fas fa-stop"></i>
</button>
</div>
</header>
<div class="tabs">
<div class="tab-buttons" role="tablist" aria-label="Tools">
<!-- Dynamically generated tab buttons from tool registry -->
<button
v-for="tool in registeredTools"
v-if="!tool.hidden"
:key="tool.id"
:class="{ active: activeTab === tool.id }"
@click="switchToTab(tool.id)"
:title="tool.title"
role="tab"
:aria-selected="activeTab === tool.id"
>
<i :class="'fas ' + tool.icon"></i> {{ tool.name }}
</button>
</div>
<div class="tab-tool-select">
<label for="mobile-tool-select">Selected Tool</label>
<select
id="mobile-tool-select"
class="mobile-tool-dropdown"
:value="activeTab"
aria-label="Selected tool"
@change="switchToTab($event.target.value)"
>
<template v-for="tool in registeredTools">
<option
v-if="!tool.hidden"
:key="tool.id"
:value="tool.id"
>{{ tool.name }}</option>
</template>
</select>
</div>
<div id="tool-content-container">
</div>
</div>
<!-- Copy History Panel -->
<div class="app-sidebar copy-history-panel" :class="{ 'active': showCopyHistory }">
<div class="app-sidebar-header copy-history-header">
<h3><i class="fas fa-history"></i> Copy History</h3>
<div class="header-actions">
<button
v-if="copyHistory.length > 0"
@click.stop="clearCopyHistory"
class="clear-history-button"
title="Clear all history"
>
<i class="fas fa-trash"></i>
</button>
<button class="close-button" @click="toggleCopyHistory" title="Close history">
<i class="fas fa-times"></i>
</button>
</div>
<div class="app-shell">
<nav class="app-nav" role="tablist" aria-label="Tools">
<div class="tab-buttons">
<button
v-for="tool in registeredTools"
v-if="!tool.hidden"
:key="tool.id"
:class="{ active: activeTab === tool.id }"
@click="switchToTab(tool.id)"
:title="tool.title"
role="tab"
:aria-selected="activeTab === tool.id"
>
<i :class="'fas ' + tool.icon"></i>
<span class="nav-label">{{ tool.name }}</span>
</button>
</div>
<div class="app-sidebar-body copy-history-content">
<div v-if="copyHistory.length === 0" class="no-history">
<p>No copy history yet. Use the app features to auto-copy content.</p>
</div>
<div v-else class="history-items">
<div v-for="(item, index) in copyHistory" :key="item.id || index" class="history-item">
<div class="history-item-header">
<span class="history-source">{{ item.source }}</span>
<span class="history-time">{{ formatHistoryTime(item.timestamp) }}</span>
</nav>
<main class="app-main">
<div class="tab-tool-select">
<label for="mobile-tool-select">Selected Tool</label>
<select
id="mobile-tool-select"
class="mobile-tool-dropdown"
:value="activeTab"
aria-label="Selected tool"
@change="switchToTab($event.target.value)"
>
<template v-for="tool in registeredTools">
<option
v-if="!tool.hidden"
:key="tool.id"
:value="tool.id"
>{{ tool.name }}</option>
</template>
</select>
</div>
<div id="tool-content-container" class="tool-content-container">
</div>
</main>
<div
v-if="mobileUtilityOpen"
class="utility-backdrop"
@click="closeMobileUtility"
aria-hidden="true"
></div>
<aside
class="app-utility"
:class="{ 'utility-open': mobileUtilityOpen }"
aria-label="Utilities"
@click.stop
>
<div class="utility-tab-bar" role="tablist" aria-label="Utility panels" @click.stop>
<button
type="button"
class="utility-tab-btn"
:class="{ active: activeUtilityPanel === 'history' }"
@click.stop="switchUtilityPanel('history')"
role="tab"
:aria-selected="activeUtilityPanel === 'history'"
>
Copy History
</button>
<button
type="button"
class="utility-tab-btn"
:class="{ active: activeUtilityPanel === 'glitch' }"
@click.stop="switchUtilityPanel('glitch')"
role="tab"
:aria-selected="activeUtilityPanel === 'glitch'"
>
Glitch Tokens
</button>
<button
type="button"
class="utility-tab-btn"
:class="{ active: activeUtilityPanel === 'endsequences' }"
@click.stop="switchUtilityPanel('endsequences')"
role="tab"
:aria-selected="activeUtilityPanel === 'endsequences'"
>
End Sequences
</button>
<button
type="button"
class="utility-tab-btn"
:class="{ active: activeUtilityPanel === 'settings' }"
@click.stop="switchUtilityPanel('settings')"
role="tab"
:aria-selected="activeUtilityPanel === 'settings'"
>
Settings
</button>
</div>
<div class="utility-panels">
<div v-show="activeUtilityPanel === 'history'" class="utility-panel copy-history-panel">
<div class="utility-panel-toolbar" v-if="copyHistory.length > 0">
<button
@click.stop="clearCopyHistory"
class="clear-history-button"
title="Clear all history"
>
<i class="fas fa-trash"></i> Clear all
</button>
</div>
<div class="utility-panel-body copy-history-content">
<div v-if="copyHistory.length === 0" class="no-history">
<p>No copy history yet. Use the app features to auto-copy content.</p>
</div>
<div class="history-content">
{{ item.content }}
</div>
<div class="history-actions">
<button class="copy-again-button" @click="copyToClipboard(item.content)" title="Copy again">
<i class="fas fa-copy"></i>
</button>
<button class="remove-history-button" @click.stop="removeFromCopyHistory(item.id)" title="Remove from history">
<i class="fas fa-times"></i>
</button>
<div v-else class="history-items">
<div v-for="(item, index) in copyHistory" :key="item.id || index" class="history-item">
<div class="history-item-header">
<span class="history-source">{{ item.source }}</span>
<span class="history-time">{{ formatHistoryTime(item.timestamp) }}</span>
</div>
<div class="history-content">
{{ item.content }}
</div>
<div class="history-actions">
<button class="copy-again-button" @click="copyToClipboard(item.content)" title="Copy again">
<i class="fas fa-copy"></i>
</button>
<button class="remove-history-button" @click.stop="removeFromCopyHistory(item.id)" title="Remove from history">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Glitch Tokens Panel -->
<div class="app-sidebar glitch-token-panel" :class="{ 'active': showGlitchTokenPanel }">
<div class="app-sidebar-header glitch-token-header">
<h3><i class="fas fa-bug"></i> Glitch Tokens</h3>
<div class="header-actions">
<button class="close-button" @click="toggleGlitchTokenPanel" title="Close glitch tokens">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="app-sidebar-body glitch-token-content">
<div v-show="activeUtilityPanel === 'glitch'" class="utility-panel glitch-token-panel">
<div class="utility-panel-body glitch-token-content">
<!-- Filter Section -->
<div class="glitch-token-filters">
<div class="filter-group">
@@ -251,147 +281,199 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- End sequences sidebar (delimiter / stop strings for research) -->
<div class="app-sidebar end-sequence-panel" :class="{ active: showEndSequencePanel }">
<div class="app-sidebar-header end-sequence-header">
<h3><i class="fas fa-stop"></i> End sequences</h3>
<div class="header-actions">
<button class="close-button" type="button" @click="toggleEndSequencePanel" title="Close">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="app-sidebar-body end-sequence-content">
<p class="end-sequence-lede">
Strings sometimes used to probe delimiter and termination behavior. Copy into your payloads as needed for authorized testing.
</p>
<div
v-for="cat in endSequenceCategories"
:key="cat.title"
class="endsequence-category"
>
<h4>{{ cat.title }}</h4>
<div class="endsequence-items">
<button
v-for="(item, idx) in cat.items"
:key="cat.title + '-' + idx"
type="button"
class="endsequence-item"
@click="copyEndSequence(item.value)"
:title="'Copy to clipboard'"
:aria-label="'Copy ' + item.label"
<div v-show="activeUtilityPanel === 'endsequences'" class="utility-panel end-sequence-panel">
<div class="utility-panel-body end-sequence-content">
<p class="end-sequence-lede">
Strings sometimes used to probe delimiter and termination behavior. Copy into your payloads as needed for authorized testing.
</p>
<div
v-for="cat in endSequenceCategories"
:key="cat.title"
class="endsequence-category"
>
<code class="endsequence-label">{{ item.label }}</code>
<span class="endsequence-copy-affordance" aria-hidden="true">
<i class="fas fa-copy"></i>
</span>
</button>
<h4>{{ cat.title }}</h4>
<div class="endsequence-items">
<button
v-for="(item, idx) in cat.items"
:key="cat.title + '-' + idx"
type="button"
class="endsequence-item"
@click="copyEndSequence(item.value)"
:title="'Copy to clipboard'"
:aria-label="'Copy ' + item.label"
>
<code class="endsequence-label">{{ item.label }}</code>
<span class="endsequence-copy-affordance" aria-hidden="true">
<i class="fas fa-copy"></i>
</span>
</button>
</div>
</div>
</div>
</div>
<div v-show="activeUtilityPanel === 'settings'" id="unicode-options-panel" class="utility-panel unicode-options-panel">
<div class="utility-panel-body unicode-panel-content">
<div class="settings-section theme-settings-section">
<h4><i class="fas fa-palette"></i> Theme</h4>
<small class="settings-hint">App appearance. Press <kbd>D</kbd> to cycle themes.</small>
<label class="settings-theme-field">
Active theme
<select
class="settings-theme-select"
:value="activeTheme"
@change="setTheme($event.target.value)"
aria-label="Select theme"
>
<option
v-for="theme in themeOptions"
:key="theme.id"
:value="theme.id"
>
{{ theme.name }}
</option>
</select>
</label>
</div>
<hr class="settings-divider" />
<div class="settings-section api-key-section">
<h4><i class="fas fa-key"></i> OpenRouter API Key</h4>
<small class="settings-hint">Required for Translation, PromptCraft, and Anti-Classifier. Stored locally in your browser only.</small>
<div class="api-key-input-row">
<input
:type="showApiKey ? 'text' : 'password'"
v-model="openrouterApiKey"
placeholder="sk-or-..."
class="api-key-input"
autocomplete="off"
spellcheck="false"
/>
<button class="api-key-toggle" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide key' : 'Show key'">
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
<div class="api-key-actions">
<button class="api-key-save" @click="saveApiKey" :disabled="!openrouterApiKey">
<i class="fas fa-save"></i> Save Key
</button>
<button class="api-key-clear" @click="clearApiKey" v-if="openrouterApiKey">
<i class="fas fa-trash"></i> Clear
</button>
<small v-if="apiKeySaved" class="apply-status">Saved</small>
</div>
</div>
<hr class="settings-divider" />
<div class="settings-section openrouter-models-section">
<h4><i class="fas fa-robot"></i> AI Models</h4>
<small class="settings-hint">Choose which models appear in tool dropdowns. Disabled models stay hidden unless currently selected.</small>
<div class="openrouter-models-toolbar">
<input
type="search"
v-model="openRouterModelsFilterQuery"
class="openrouter-models-search"
placeholder="Search models…"
autocomplete="off"
/>
<button type="button" class="openrouter-models-action" @click="enableAllOpenRouterModels">Show all</button>
<button type="button" class="openrouter-models-action" @click="showFreeOpenRouterModelsOnly">Free only</button>
<button type="button" class="openrouter-models-action" @click="refreshOpenRouterModels(true)" :disabled="openRouterModelsLoading">
<i class="fas" :class="openRouterModelsLoading ? 'fa-spinner fa-spin' : 'fa-sync-alt'"></i>
</button>
</div>
<small class="openrouter-models-summary">
{{ openRouterModels.length }} shown in dropdowns · {{ openRouterModelsCatalog.length }} loaded
<span v-if="openRouterModelsDisabled.length"> · {{ openRouterModelsDisabled.length }} hidden</span>
</small>
<small v-if="openRouterModelsError" class="openrouter-model-hint openrouter-model-hint-error">{{ openRouterModelsError }}</small>
<div class="openrouter-models-list" role="group" aria-label="Model visibility">
<label
v-for="model in filteredOpenRouterModelsCatalog"
:key="model.id"
class="openrouter-model-toggle-row"
:class="{ 'is-virtual-router': model.virtual }"
>
<input
type="checkbox"
:checked="isOpenRouterModelEnabled(model.id)"
@change="toggleOpenRouterModelEnabled(model.id)"
/>
<span class="openrouter-model-toggle-text">
<span class="openrouter-model-toggle-name">{{ formatOpenRouterModelLabel(model) }}</span>
<small v-if="model.summary" class="openrouter-model-toggle-summary">{{ model.summary }}</small>
</span>
</label>
</div>
</div>
<hr class="settings-divider" />
<h4><i class="fas fa-user-secret"></i> Emoji Steganography</h4>
<small class="settings-hint">Global encoding options for the Steganography tool (emoji carrier bit patterns). Not used by Unicode style transforms.</small>
<div class="options-grid steg-adv-panel">
<label>
Initial Presentation
<select class="steg-initial-presentation">
<option value="emoji">Emoji (VS16)</option>
<option value="text">Text (VS15)</option>
<option value="none">None</option>
</select>
</label>
<label>
Bit-0 Selector
<select class="steg-vs-zero">
<option value="\ufe0e">VS15 (\ufe0e)</option>
<option value="\ufe0f">VS16 (\ufe0f)</option>
</select>
</label>
<label>
Bit-1 Selector
<select class="steg-vs-one">
<option value="\ufe0f">VS16 (\ufe0f)</option>
<option value="\ufe0e">VS15 (\ufe0e)</option>
</select>
</label>
<label>
Inter-bit Zero-Width
<select class="steg-inter-zw">
<option value="">None</option>
<option value="\u200C">ZWNJ (\u200C)</option>
<option value="\u200D">ZWJ (\u200D)</option>
<option value="\u200B">ZWSP (\u200B)</option>
<option value="\ufeff">BOM (\ufeff)</option>
</select>
</label>
<label>
Inter-bit Every N bits
<input class="steg-inter-every" type="number" min="1" max="8" value="1" />
</label>
<label>
Bit Order
<select class="steg-bit-order">
<option value="msb">MSB First</option>
<option value="lsb">LSB First</option>
</select>
</label>
<label>
Trailing Zero-Width
<select class="steg-trailing-zw">
<option value="\u200B">ZWSP (\u200B)</option>
<option value="\u200C">ZWNJ (\u200C)</option>
<option value="\u200D">ZWJ (\u200D)</option>
<option value="\ufeff">BOM (\ufeff)</option>
<option value="">None</option>
</select>
</label>
<div style="display:flex; align-items:center; gap:10px;">
<button class="apply-steg-options" :class="{ applied: unicodeApplyFlash }" :disabled="unicodeApplyBusy" @click="applyUnicodeOptions" title="These options affect Unicode-based steganography encoding/decoding.">Apply</button>
<small v-if="unicodeApplyFlash" class="apply-status">Applied</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Advanced Settings Panel (inside app so Vue bindings work) -->
<div id="unicode-options-panel" class="app-sidebar unicode-options-panel">
<div class="app-sidebar-header unicode-panel-header">
<h3><i class="fas fa-sliders-h"></i> Advanced Settings</h3>
<button class="close-button" title="Close Advanced Settings"><i class="fas fa-times"></i></button>
</div>
<div class="app-sidebar-body unicode-panel-content">
<!-- OpenRouter API Key -->
<div class="settings-section api-key-section">
<h4><i class="fas fa-key"></i> OpenRouter API Key</h4>
<small class="settings-hint">Required for Translation, PromptCraft, and Anti-Classifier. Stored locally in your browser only.</small>
<div class="api-key-input-row">
<input
:type="showApiKey ? 'text' : 'password'"
v-model="openrouterApiKey"
placeholder="sk-or-..."
class="api-key-input"
autocomplete="off"
spellcheck="false"
/>
<button class="api-key-toggle" @click="showApiKey = !showApiKey" :title="showApiKey ? 'Hide key' : 'Show key'">
<i :class="showApiKey ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
<div class="api-key-actions">
<button class="api-key-save" @click="saveApiKey" :disabled="!openrouterApiKey">
<i class="fas fa-save"></i> Save Key
</button>
<button class="api-key-clear" @click="clearApiKey" v-if="openrouterApiKey">
<i class="fas fa-trash"></i> Clear
</button>
<small v-if="apiKeySaved" class="apply-status">Saved</small>
</div>
</div>
<hr class="settings-divider" />
<!-- Steganography Options -->
<h4><i class="fas fa-user-secret"></i> Steganography Options</h4>
</div>
<div class="app-sidebar-body unicode-panel-content options-grid steg-adv-panel">
<label>
Initial Presentation
<select class="steg-initial-presentation">
<option value="emoji">Emoji (VS16)</option>
<option value="text">Text (VS15)</option>
<option value="none">None</option>
</select>
</label>
<label>
Bit-0 Selector
<select class="steg-vs-zero">
<option value="\ufe0e">VS15 (\ufe0e)</option>
<option value="\ufe0f">VS16 (\ufe0f)</option>
</select>
</label>
<label>
Bit-1 Selector
<select class="steg-vs-one">
<option value="\ufe0f">VS16 (\ufe0f)</option>
<option value="\ufe0e">VS15 (\ufe0e)</option>
</select>
</label>
<label>
Inter-bit Zero-Width
<select class="steg-inter-zw">
<option value="">None</option>
<option value="\u200C">ZWNJ (\u200C)</option>
<option value="\u200D">ZWJ (\u200D)</option>
<option value="\u200B">ZWSP (\u200B)</option>
<option value="\ufeff">BOM (\ufeff)</option>
</select>
</label>
<label>
Inter-bit Every N bits
<input class="steg-inter-every" type="number" min="1" max="8" value="1" />
</label>
<label>
Bit Order
<select class="steg-bit-order">
<option value="msb">MSB First</option>
<option value="lsb">LSB First</option>
</select>
</label>
<label>
Trailing Zero-Width
<select class="steg-trailing-zw">
<option value="\u200B">ZWSP (\u200B)</option>
<option value="\u200C">ZWNJ (\u200C)</option>
<option value="\u200D">ZWJ (\u200D)</option>
<option value="\ufeff">BOM (\ufeff)</option>
<option value="">None</option>
</select>
</label>
<div style="display:flex; align-items:center; gap:10px;">
<button class="apply-steg-options" :class="{ applied: unicodeApplyFlash }" :disabled="unicodeApplyBusy" @click="applyUnicodeOptions" title="These options affect Unicode-based steganography encoding/decoding.">Apply</button>
<small v-if="unicodeApplyFlash" class="apply-status">Applied</small>
</div>
</div>
</aside>
</div>
</div>
@@ -423,6 +505,7 @@
<script src="js/utils/history.js"></script>
<script src="js/utils/clipboard.js"></script>
<script src="js/utils/theme.js"></script>
<script src="js/utils/openrouterModels.js"></script>
<script src="js/utils/routing.js"></script>
<script src="js/utils/emoji.js"></script>
+321 -49
View File
@@ -1,5 +1,8 @@
const baseData = {
isDarkTheme: true,
activeTheme: 'dark',
themeOptions: (window.ThemeUtils && window.ThemeUtils.getThemes)
? window.ThemeUtils.getThemes()
: [{ id: 'dark', name: 'Dark' }, { id: 'light', name: 'Light' }],
activeTab: 'transforms',
registeredTools: [],
universalDecodeInput: '',
@@ -14,6 +17,9 @@ const baseData = {
copyHistory: [],
maxHistoryItems: window.CONFIG.MAX_HISTORY_ITEMS,
showCopyHistory: false,
activeUtilityPanel: 'history',
mobileUtilityOpen: false,
utilityPanelInteractionGuard: false,
showUnicodePanel: false,
unicodeApplyBusy: false,
unicodeApplyFlash: false,
@@ -33,7 +39,20 @@ const baseData = {
allGlitchTokens: [],
openrouterApiKey: localStorage.getItem('openrouter-api-key') || '',
showApiKey: false,
apiKeySaved: false
apiKeySaved: false,
openRouterModels: (window.OpenRouterModels && window.OpenRouterModels.getStaticFallback)
? window.OpenRouterModels.getStaticFallback()
: [],
openRouterModelsCatalog: (window.OpenRouterModels && window.OpenRouterModels.getStaticFallback)
? window.OpenRouterModels.getStaticFallback()
: [],
openRouterModelsDisabled: (window.OpenRouterModels && window.OpenRouterModels.loadDisabledIds)
? window.OpenRouterModels.loadDisabledIds()
: [],
openRouterModelsFilterQuery: '',
openRouterModelsLoading: false,
openRouterModelsError: '',
openRouterModelsKeyInfo: null
};
const toolData = (window.toolRegistry && typeof window.toolRegistry.mergeVueData === 'function')
@@ -45,21 +64,107 @@ const toolMethods = (window.toolRegistry && typeof window.toolRegistry.mergeVueM
? window.toolRegistry.mergeVueMethods()
: {};
Vue.component('openrouter-model-select', {
props: {
value: { type: String, default: '' },
label: { type: String, default: 'Model' },
showRefresh: { type: Boolean, default: true }
},
computed: {
models: function() {
return this.$root.openRouterModels || [];
},
loading: function() {
return this.$root.openRouterModelsLoading;
},
error: function() {
return this.$root.openRouterModelsError;
},
keyInfo: function() {
return this.$root.openRouterModelsKeyInfo;
},
hasApiKey: function() {
return this.$root.getOpenRouterApiKey ? !!this.$root.getOpenRouterApiKey() : false;
},
routerHint: function() {
if (!this.value || !window.OpenRouterModels || !window.OpenRouterModels.getRouterHint) {
return '';
}
return window.OpenRouterModels.getRouterHint(this.value);
}
},
methods: {
onChange: function(event) {
this.$emit('input', event.target.value);
},
refresh: function() {
if (this.$root.refreshOpenRouterModels) {
this.$root.refreshOpenRouterModels(true);
}
},
formatLabel: function(model) {
return this.$root.formatOpenRouterModelLabel
? this.$root.formatOpenRouterModelLabel(model)
: (model && model.name) || '';
}
},
template:
'<label class="openrouter-model-picker">' +
'<span class="openrouter-model-label">{{ label }}</span>' +
'<div class="openrouter-model-row">' +
'<select ' +
'class="openrouter-model-select" ' +
':value="value" ' +
'@change="onChange" ' +
':disabled="loading && !models.length"' +
'>' +
'<option v-if="loading && !models.length" disabled value="">Loading models…</option>' +
'<option v-for="m in models" :key="m.id" :value="m.id">{{ formatLabel(m) }}</option>' +
'</select>' +
'<button ' +
'v-if="showRefresh" ' +
'type="button" ' +
'class="openrouter-model-refresh" ' +
'@click="refresh" ' +
':disabled="loading" ' +
'title="Refresh model list from OpenRouter"' +
'aria-label="Refresh model list"' +
'>' +
'<i class="fas" :class="loading ? \'fa-spinner fa-spin\' : \'fa-sync-alt\'"></i>' +
'</button>' +
'</div>' +
'<small v-if="error" class="openrouter-model-hint openrouter-model-hint-error">{{ error }}</small>' +
'<small v-else-if="routerHint" class="openrouter-model-hint openrouter-model-hint-router">{{ routerHint }}</small>' +
'<small v-else-if="!hasApiKey" class="openrouter-model-hint">Add an OpenRouter key in Settings to load models for your account.</small>' +
'<small v-else-if="keyInfo && keyInfo.is_free_tier" class="openrouter-model-hint">Free tier account — models marked · free need no credits.</small>' +
'<small v-else class="openrouter-model-hint">Curate visible models in Settings → AI Models.</small>' +
'</label>'
});
window.app = new Vue({
el: '#app',
data: mergedData,
computed: {
filteredOpenRouterModelsCatalog: function() {
var query = (this.openRouterModelsFilterQuery || '').trim().toLowerCase();
var catalog = this.openRouterModelsCatalog || [];
if (!query) return catalog;
return catalog.filter(function(model) {
var haystack = [
model.id,
model.name,
model.provider,
model.summary
].filter(Boolean).join(' ').toLowerCase();
return haystack.indexOf(query) !== -1;
});
}
},
methods: Object.assign({}, toolMethods || {}, {
toggleUnicodePanel(event) {
if (this.unicodePanelToggleLock) return;
this.unicodePanelToggleLock = true;
this.showUnicodePanel = !this.showUnicodePanel;
const panel = document.getElementById('unicode-options-panel');
if (panel) {
if (this.showUnicodePanel) panel.classList.add('active');
else panel.classList.remove('active');
}
this.switchUtilityPanel('settings');
setTimeout(() => {
this.unicodePanelToggleLock = false;
}, 300);
@@ -211,34 +316,74 @@ window.app = new Vue({
}
},
toggleTheme() {
this.isDarkTheme = window.ThemeUtils.toggleTheme(this.isDarkTheme);
setTheme(themeId) {
if (!window.ThemeUtils) return;
this.activeTheme = window.ThemeUtils.applyTheme(themeId);
},
cycleTheme() {
if (!window.ThemeUtils) return;
this.activeTheme = window.ThemeUtils.cycleTheme(this.activeTheme);
},
toggleCopyHistory() {
this.showCopyHistory = !this.showCopyHistory;
if (this.showCopyHistory && this.copyHistory.length > 0) {
this.$nextTick(() => {
const firstCopyButton = document.querySelector('.copy-again-button');
this.switchUtilityPanel('history');
},
toggleGlitchTokenPanel() {
this.switchUtilityPanel('glitch');
},
toggleEndSequencePanel() {
this.switchUtilityPanel('endsequences');
},
markUtilityPanelInteraction() {
this.utilityPanelInteractionGuard = true;
var self = this;
setTimeout(function() {
self.utilityPanelInteractionGuard = false;
}, 400);
},
switchUtilityPanel(panelId) {
var isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
this.activeUtilityPanel = panelId;
this.showCopyHistory = panelId === 'history';
this.showGlitchTokenPanel = panelId === 'glitch';
this.showEndSequencePanel = panelId === 'endsequences';
this.showUnicodePanel = panelId === 'settings';
if (isMobile) {
this.mobileUtilityOpen = true;
this.markUtilityPanelInteraction();
}
if (panelId === 'glitch' && !this.glitchTokensLoaded) {
this.loadGlitchTokens();
}
if (panelId === 'history' && this.copyHistory.length > 0) {
this.$nextTick(function() {
var firstCopyButton = document.querySelector('.copy-again-button');
if (firstCopyButton) {
firstCopyButton.focus();
}
});
}
},
toggleGlitchTokenPanel(event) {
this.showGlitchTokenPanel = !this.showGlitchTokenPanel;
// Load tokens if not already loaded
if (this.showGlitchTokenPanel && !this.glitchTokensLoaded) {
this.loadGlitchTokens();
closeMobileUtility() {
if (this.utilityPanelInteractionGuard) {
return;
}
this.mobileUtilityOpen = false;
},
toggleEndSequencePanel() {
this.showEndSequencePanel = !this.showEndSequencePanel;
openMobileUtility() {
this.mobileUtilityOpen = true;
this.markUtilityPanelInteraction();
},
copyEndSequence(value) {
@@ -397,6 +542,133 @@ window.app = new Vue({
showCopiedPopup() {
window.NotificationUtils.showCopiedPopup();
},
getOpenRouterApiKey() {
var key = (this.openrouterApiKey || '').trim();
if (key) return key;
if (window.OpenRouterModels && window.OpenRouterModels.getApiKey) {
return window.OpenRouterModels.getApiKey();
}
return '';
},
formatOpenRouterModelLabel(model) {
if (window.OpenRouterModels && window.OpenRouterModels.formatLabel) {
return window.OpenRouterModels.formatLabel(model);
}
return model && model.name ? model.name : '';
},
isOpenRouterModelEnabled(modelId) {
if (!window.OpenRouterModels) return true;
return window.OpenRouterModels.isModelEnabled(modelId, this.openRouterModelsDisabled);
},
rebuildOpenRouterDropdown() {
if (!window.OpenRouterModels) return;
var pinned = window.OpenRouterModels.getPinnedModelIds(this);
this.openRouterModels = window.OpenRouterModels.filterForDropdown(
this.openRouterModelsCatalog,
this.openRouterModelsDisabled,
pinned
);
},
toggleOpenRouterModelEnabled(modelId) {
if (!modelId) return;
var disabled = this.openRouterModelsDisabled.slice();
var index = disabled.indexOf(modelId);
if (index === -1) {
disabled.push(modelId);
} else {
disabled.splice(index, 1);
}
this.openRouterModelsDisabled = disabled;
if (window.OpenRouterModels) {
window.OpenRouterModels.saveDisabledIds(disabled);
}
this.rebuildOpenRouterDropdown();
this.syncOpenRouterModelSelections();
},
enableAllOpenRouterModels() {
this.openRouterModelsDisabled = [];
if (window.OpenRouterModels) {
window.OpenRouterModels.saveDisabledIds([]);
}
this.rebuildOpenRouterDropdown();
this.syncOpenRouterModelSelections();
},
showFreeOpenRouterModelsOnly() {
var disabled = (this.openRouterModelsCatalog || [])
.filter(function(model) {
return model && model.id && !model.free && !model.virtual;
})
.map(function(model) { return model.id; });
this.openRouterModelsDisabled = disabled;
if (window.OpenRouterModels) {
window.OpenRouterModels.saveDisabledIds(disabled);
}
this.rebuildOpenRouterDropdown();
this.syncOpenRouterModelSelections();
},
syncOpenRouterModelSelections() {
if (!window.OpenRouterModels || !this.openRouterModels.length) return;
var models = this.openRouterModels;
var ensure = window.OpenRouterModels.ensureValidSelection.bind(window.OpenRouterModels);
if (typeof this.pcModel !== 'undefined') {
this.pcModel = ensure(this.pcModel, models, localStorage.getItem('pc-model') || 'openrouter/auto');
}
if (typeof this.acModel !== 'undefined') {
this.acModel = ensure(this.acModel, models, localStorage.getItem('ac-model') || 'openrouter/auto');
}
if (typeof this.saModel !== 'undefined') {
this.saModel = ensure(this.saModel, models, localStorage.getItem('sa-model') || 'openrouter/free');
}
if (typeof this.translateModel !== 'undefined') {
this.translateModel = ensure(this.translateModel, models, localStorage.getItem('translate-model') || 'google/gemma-3-27b-it');
}
},
refreshOpenRouterModels: async function(force) {
if (!window.OpenRouterModels) return;
if (this.openRouterModelsLoading) return;
this.openRouterModelsLoading = true;
this.openRouterModelsError = '';
var apiKey = this.getOpenRouterApiKey();
try {
var models = await window.OpenRouterModels.fetch(apiKey, { force: !!force });
this.openRouterModelsCatalog = models;
this.rebuildOpenRouterDropdown();
this.syncOpenRouterModelSelections();
if (apiKey) {
this.openRouterModelsKeyInfo = await window.OpenRouterModels.fetchKeyInfo(apiKey);
} else {
this.openRouterModelsKeyInfo = null;
}
} catch (err) {
console.warn('OpenRouter model fetch failed:', err);
var fallback = window.OpenRouterModels.getStaticFallback();
this.openRouterModelsCatalog = window.OpenRouterModels.mergeWithVirtual(fallback);
this.rebuildOpenRouterDropdown();
this.syncOpenRouterModelSelections();
if (err && err.status === 401) {
this.openRouterModelsError = 'Invalid API key — check Settings.';
} else {
this.openRouterModelsError = (err && err.message) || 'Could not load models; using offline list.';
}
} finally {
this.openRouterModelsLoading = false;
}
},
saveApiKey() {
var trimmed = (this.openrouterApiKey || '').trim();
@@ -406,6 +678,7 @@ window.app = new Vue({
this.apiKeySaved = true;
this.showNotification('API key saved', 'success');
setTimeout(() => { this.apiKeySaved = false; }, 2000);
this.refreshOpenRouterModels(true);
}
},
@@ -416,6 +689,8 @@ window.app = new Vue({
localStorage.removeItem('openrouter_api_key');
localStorage.removeItem('plinyos-api-key');
this.showNotification('API key cleared', 'success');
this.openRouterModelsKeyInfo = null;
this.refreshOpenRouterModels(true);
},
setupPasteHandlers() {
@@ -431,15 +706,19 @@ window.app = new Vue({
}
}),
mounted() {
if (window.ThemeUtils && window.ThemeUtils.initializeTheme) {
this.isDarkTheme = window.ThemeUtils.initializeTheme();
if (this.isDarkTheme) {
document.body.classList.add('dark-theme');
} else {
document.body.classList.add('light-theme');
}
} else if (this.isDarkTheme) {
document.body.classList.add('dark-theme');
if (window.ThemeUtils) {
this.activeTheme = window.ThemeUtils.applyTheme(window.ThemeUtils.initializeTheme());
this._themeKeyHandler = (event) => {
if (event.defaultPrevented || event.repeat) return;
if (event.key !== 'd' && event.key !== 'D') return;
var tag = event.target && event.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (event.target && event.target.isContentEditable)) {
return;
}
event.preventDefault();
this.cycleTheme();
};
document.addEventListener('keydown', this._themeKeyHandler);
}
if (window.toolRegistry && typeof window.toolRegistry.mergeVueLifecycle === 'function') {
@@ -453,6 +732,8 @@ window.app = new Vue({
this.registeredTools = window.toolRegistry.getAll();
}
this.refreshOpenRouterModels(false);
var initialRoute = window.TabRouting && window.TabRouting.parse();
if (initialRoute && initialRoute.tab && this.getValidToolIds().includes(initialRoute.tab)) {
this.applyRouteFromHash();
@@ -468,20 +749,6 @@ window.app = new Vue({
};
window.addEventListener('hashchange', this._onHashChange);
this.$nextTick(() => {
const closeButton = document.querySelector('#unicode-options-panel .close-button');
if (closeButton) {
const handleClose = (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleUnicodePanel(e);
};
closeButton.addEventListener('click', handleClose, { passive: false });
closeButton.addEventListener('touchend', handleClose, { passive: false });
}
});
document.addEventListener('click', (e) => {
if (e.target.closest('.custom-tooltip')) {
return;
@@ -583,6 +850,11 @@ window.app = new Vue({
},
beforeDestroy() {
if (this._themeKeyHandler) {
document.removeEventListener('keydown', this._themeKeyHandler);
this._themeKeyHandler = null;
}
if (this._onHashChange) {
window.removeEventListener('hashchange', this._onHashChange);
this._onHashChange = null;
+2
View File
@@ -2,6 +2,8 @@
* Application Configuration Constants
*/
window.CONFIG = {
APP_VERSION: '4.0.0',
// History configuration
MAX_HISTORY_ITEMS: 50,
+14 -46
View File
@@ -1,51 +1,19 @@
/**
* Shared OpenRouter model list for PromptCraft, Anti-Classifier, etc.
* Loaded before tool scripts.
* Offline fallback when OpenRouter model API is unreachable.
* Live lists are loaded via OpenRouterModels.fetch() using the user's API key.
*/
window.OPENROUTER_MODELS = [
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', provider: 'Anthropic' },
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic' },
{ id: 'anthropic/claude-sonnet-4.5', name: 'Claude Sonnet 4.5', provider: 'Anthropic' },
{ id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', provider: 'Anthropic' },
{ id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'Anthropic' },
{ id: 'openai/gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI' },
{ id: 'openai/gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI' },
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
{ id: 'google/gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', provider: 'Google' },
{ id: 'google/gemini-2.5-pro-preview', name: 'Gemini 2.5 Pro', provider: 'Google' },
{ id: 'x-ai/grok-4.20-beta', name: 'Grok 4.20 Beta', provider: 'xAI' },
{ id: 'x-ai/grok-4', name: 'Grok 4', provider: 'xAI' },
{ id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', provider: 'DeepSeek' },
{ id: 'mistralai/mistral-large-3-2512', name: 'Mistral Large 3', provider: 'Mistral' },
{ id: 'openai/o3-pro', name: 'o3-pro', provider: 'OpenAI' },
{ id: 'openai/o3', name: 'o3', provider: 'OpenAI' },
{ id: 'openai/o4-mini', name: 'o4-mini', provider: 'OpenAI' },
{ id: 'deepseek/deepseek-r1-0528', name: 'DeepSeek R1 (0528)', provider: 'DeepSeek' },
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', provider: 'DeepSeek' },
{ id: 'qwen/qwq-32b', name: 'QwQ 32B', provider: 'Qwen' },
{ id: 'anthropic/claude-haiku-4.5', name: 'Claude Haiku 4.5', provider: 'Anthropic' },
{ id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'OpenAI' },
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', provider: 'OpenAI' },
{ id: 'openai/gpt-4.1-nano', name: 'GPT-4.1 Nano', provider: 'OpenAI' },
{ id: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', provider: 'Google' },
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', provider: 'Google' },
{ id: 'google/gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', provider: 'Google' },
{ id: 'x-ai/grok-4.1-fast', name: 'Grok 4.1 Fast', provider: 'xAI' },
window.OPENROUTER_MODELS_FALLBACK = [
{ id: 'openrouter/free', name: 'Free router', summary: 'Zero cost — random free model', provider: 'OpenRouter', virtual: true },
{ id: 'openrouter/auto', name: 'Auto router', summary: 'Smart routing — billed per model', provider: 'OpenRouter', virtual: true },
{ id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', provider: 'Google' },
{ id: 'qwen/qwen3-coder-480b-a35b-instruct', name: 'Qwen3 Coder 480B', provider: 'Qwen' },
{ id: 'openai/gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' },
{ id: 'x-ai/grok-code-fast-1', name: 'Grok Code Fast 1', provider: 'xAI' },
{ id: 'mistralai/devstral-2-2512', name: 'Devstral 2', provider: 'Mistral' },
{ id: 'mistralai/codestral-2508', name: 'Codestral', provider: 'Mistral' },
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', provider: 'Meta' },
{ id: 'meta-llama/llama-4-scout', name: 'Llama 4 Scout', provider: 'Meta' },
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', provider: 'Meta' },
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', provider: 'Qwen' },
{ id: 'google/gemma-3-12b-it', name: 'Gemma 3 12B', provider: 'Google' },
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic' },
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', provider: 'OpenAI' },
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', provider: 'Google' },
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', provider: 'DeepSeek' },
{ id: 'cohere/command-a', name: 'Command A', provider: 'Cohere' },
{ id: 'nousresearch/hermes-3-llama-3.1-405b', name: 'Hermes 3 405B', provider: 'Nous' },
{ id: 'perplexity/sonar-deep-research', name: 'Sonar Deep Research', provider: 'Perplexity' },
{ id: 'perplexity/sonar-pro', name: 'Sonar Pro', provider: 'Perplexity' },
{ id: 'openrouter/free', name: 'Free (auto-pick)', provider: 'OpenRouter' },
{ id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', provider: 'Meta' },
{ id: 'nousresearch/hermes-3-llama-3.1-405b', name: 'Hermes 3 405B', provider: 'Nous' }
];
/** @deprecated Use OPENROUTER_MODELS_FALLBACK or OpenRouterModels.fetch() */
window.OPENROUTER_MODELS = window.OPENROUTER_MODELS_FALLBACK;
+1 -5
View File
@@ -13,9 +13,6 @@ class AntiClassifierTool extends Tool {
}
getVueData() {
const models = (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length)
? window.OPENROUTER_MODELS
: [];
const savedTemp = parseFloat(localStorage.getItem('ac-temperature'));
const acTemperature = Number.isFinite(savedTemp)
? Math.min(2, Math.max(0, savedTemp))
@@ -26,8 +23,7 @@ class AntiClassifierTool extends Tool {
acError: '',
acLexemeAnalysis: { totalFindings: 0, findings: [], summary: 'No Latin-root wording findings.' },
acLoading: false,
acModel: localStorage.getItem('ac-model') || 'anthropic/claude-sonnet-4.6',
acModels: models,
acModel: localStorage.getItem('ac-model') || 'openrouter/auto',
acTemperature,
acMaxTokens: 2000
};
+2 -7
View File
@@ -23,7 +23,7 @@ class PromptCraftTool extends Tool {
pcOutputs: [],
pcLexemeAnalysis: { totalFindings: 0, findings: [], summary: 'No Latin-root wording findings.' },
pcStrategy: 'rephrase',
pcModel: localStorage.getItem('pc-model') || 'nousresearch/hermes-3-llama-3.1-405b',
pcModel: localStorage.getItem('pc-model') || 'openrouter/auto',
pcTemperature,
pcCount: 3,
pcLoading: false,
@@ -39,12 +39,7 @@ class PromptCraftTool extends Tool {
{ id: 'metaphor', name: 'Metaphor', icon: 'fa-cloud', desc: 'Express through analogy and metaphor' },
{ id: 'fragment', name: 'Fragment', icon: 'fa-puzzle-piece', desc: 'Split across disjointed fragments' },
{ id: 'custom', name: 'Custom', icon: 'fa-pen-fancy', desc: 'Your own mutation instruction' }
],
pcModels: (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length)
? window.OPENROUTER_MODELS
: [
{ id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }
]
]
};
}
+1 -27
View File
@@ -13,31 +13,6 @@ class SpellingAlphabetTool extends Tool {
}
getVueData() {
var freeModels = [
{ id: 'openrouter/free', name: 'Free (auto-pick)', provider: 'OpenRouter' },
{ id: 'google/gemma-3-27b-it:free', name: 'Gemma 3 27B (free)', provider: 'Google' },
{ id: 'google/gemma-3-12b-it:free', name: 'Gemma 3 12B (free)', provider: 'Google' },
{ id: 'google/gemma-3-4b-it:free', name: 'Gemma 3 4B (free)', provider: 'Google' },
{ id: 'meta-llama/llama-3.3-70b-instruct:free', name: 'Llama 3.3 70B (free)', provider: 'Meta' }
];
var allModels = (typeof window !== 'undefined' && window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length)
? window.OPENROUTER_MODELS
: [{ id: 'openrouter/auto', name: 'Auto (best for price)', provider: 'OpenRouter' }];
var seen = {};
var saModels = [];
freeModels.forEach(function(model) {
if (!seen[model.id]) {
seen[model.id] = true;
saModels.push(model);
}
});
allModels.forEach(function(model) {
if (!seen[model.id]) {
seen[model.id] = true;
saModels.push(model);
}
});
return {
saView: 'list',
saAlphabets: [],
@@ -48,8 +23,7 @@ class SpellingAlphabetTool extends Tool {
saLoading: false,
saError: '',
saModel: localStorage.getItem('sa-model') || 'openrouter/free',
saLetters: SpellingAlphabetTransform.LETTERS,
saModels: saModels
saLetters: SpellingAlphabetTransform.LETTERS
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ class SplitterTool extends Tool {
super({
id: 'splitter',
name: 'Splitter',
icon: 'fa-grip-lines',
icon: 'fa-scissors',
title: 'Split text into multiple copyable messages',
order: 8
});
-10
View File
@@ -40,16 +40,6 @@ class TranslateTool extends Tool {
translateError: '',
translateActiveLang: '',
translateModel: localStorage.getItem('translate-model') || 'google/gemma-3-27b-it',
translateModels: [
{ id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', note: 'Best quality' },
{ id: 'google/gemma-3-12b-it', name: 'Gemma 3 12B', note: 'Fast + good' },
{ id: 'google/gemma-3-4b-it', name: 'Gemma 3 4B', note: 'Fastest' },
{ id: 'google/gemini-2.5-flash-preview', name: 'Gemini 2.5 Flash', note: 'Google flagship' },
{ id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash', note: 'Stable' },
{ id: 'google/translategemma-27b-it', name: 'TranslateGemma 27B', note: 'Purpose-built (if available)' },
{ id: 'google/translategemma-12b-it', name: 'TranslateGemma 12B', note: 'Purpose-built (if available)' },
{ id: 'google/translategemma-4b-it', name: 'TranslateGemma 4B', note: 'Purpose-built (if available)' }
],
translateMainLangs: [
{ code: 'es', name: 'Spanish', flag: 'ES' },
{ code: 'fr', name: 'French', flag: 'FR' },
+241
View File
@@ -0,0 +1,241 @@
/**
* Fetch, cache, and normalize OpenRouter model lists for UI dropdowns.
*/
window.OpenRouterModels = {
CACHE_KEY: 'openrouter-models-cache-v1',
CACHE_TTL_MS: 60 * 60 * 1000,
ENABLED_STORAGE_KEY: 'openrouter-models-disabled-v1',
VIRTUAL: [
{
id: 'openrouter/free',
name: 'Free router',
summary: 'Zero cost — random free model matched to your request',
provider: 'OpenRouter',
virtual: true,
routerKind: 'free'
},
{
id: 'openrouter/auto',
name: 'Auto router',
summary: 'Smart routing — billed at whichever model is picked',
provider: 'OpenRouter',
virtual: true,
routerKind: 'auto'
}
],
getApiKey: function() {
try {
return (
localStorage.getItem('openrouter-api-key') ||
localStorage.getItem('plinyos-api-key') ||
localStorage.getItem('openrouter_api_key') ||
''
).trim();
} catch (e) {
return '';
}
},
getStaticFallback: function() {
if (window.OPENROUTER_MODELS_FALLBACK && window.OPENROUTER_MODELS_FALLBACK.length) {
return window.OPENROUTER_MODELS_FALLBACK.slice();
}
if (window.OPENROUTER_MODELS && window.OPENROUTER_MODELS.length) {
return window.OPENROUTER_MODELS.slice();
}
return this.VIRTUAL.slice();
},
isFreePricing: function(pricing) {
if (!pricing) return false;
return String(pricing.prompt) === '0' && String(pricing.completion) === '0';
},
normalize: function(raw) {
var id = raw && raw.id ? raw.id : '';
var provider = id.indexOf('/') !== -1 ? id.split('/')[0] : '';
return {
id: id,
name: (raw && raw.name) || id,
provider: provider,
free: this.isFreePricing(raw && raw.pricing),
contextLength: raw && raw.context_length ? raw.context_length : null,
pricing: raw && raw.pricing ? raw.pricing : null
};
},
formatLabel: function(model) {
if (!model) return '';
if (model.virtual) {
return model.name + ' — ' + (model.summary || model.provider);
}
var label = model.name || model.id;
if (model.provider) {
label += ' (' + model.provider + ')';
}
if (model.free) {
label += ' · free';
}
return label;
},
getRouterHint: function(modelId) {
var match = this.VIRTUAL.find(function(model) {
return model.id === modelId;
});
return match ? match.summary : '';
},
loadDisabledIds: function() {
try {
var raw = localStorage.getItem(this.ENABLED_STORAGE_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
},
saveDisabledIds: function(ids) {
try {
localStorage.setItem(this.ENABLED_STORAGE_KEY, JSON.stringify(ids || []));
} catch (e) {
console.warn('Failed to save OpenRouter model preferences:', e);
}
},
isModelEnabled: function(modelId, disabledIds) {
if (!modelId) return true;
disabledIds = disabledIds || [];
return disabledIds.indexOf(modelId) === -1;
},
filterForDropdown: function(catalog, disabledIds, pinnedIds) {
catalog = catalog || [];
disabledIds = disabledIds || [];
pinnedIds = pinnedIds || [];
return catalog.filter(function(model) {
if (!model || !model.id) return false;
if (pinnedIds.indexOf(model.id) !== -1) return true;
return disabledIds.indexOf(model.id) === -1;
});
},
getPinnedModelIds: function(context) {
context = context || {};
var ids = [];
['pcModel', 'acModel', 'saModel', 'translateModel'].forEach(function(field) {
if (context[field]) ids.push(context[field]);
});
return ids;
},
loadCache: function(apiKey) {
try {
var raw = localStorage.getItem(this.CACHE_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
if (parsed.apiKey !== (apiKey || '')) return null;
if (Date.now() - parsed.fetchedAt > this.CACHE_TTL_MS) return null;
return Array.isArray(parsed.models) ? parsed.models : null;
} catch (e) {
return null;
}
},
saveCache: function(apiKey, models) {
try {
localStorage.setItem(this.CACHE_KEY, JSON.stringify({
apiKey: apiKey || '',
fetchedAt: Date.now(),
models: models
}));
} catch (e) {
console.warn('Failed to cache OpenRouter models:', e);
}
},
mergeWithVirtual: function(models) {
var seen = {};
var merged = [];
this.VIRTUAL.forEach(function(model) {
seen[model.id] = true;
merged.push(Object.assign({}, model));
});
(models || []).forEach(function(model) {
if (!model || !model.id || seen[model.id]) return;
seen[model.id] = true;
merged.push(model);
});
return merged;
},
fetch: async function(apiKey, options) {
options = options || {};
var key = (apiKey || '').trim();
if (!options.force) {
var cached = this.loadCache(key);
if (cached && cached.length) {
return cached;
}
}
var url = key
? 'https://openrouter.ai/api/v1/models/user'
: 'https://openrouter.ai/api/v1/models?sort=most-popular';
var headers = {};
if (key) {
headers.Authorization = 'Bearer ' + key;
}
var resp = await fetch(url, { headers: headers });
if (!resp.ok) {
var error = new Error('Failed to load models (HTTP ' + resp.status + ')');
error.status = resp.status;
throw error;
}
var json = await resp.json();
var models = (json.data || []).map(this.normalize.bind(this));
models = this.mergeWithVirtual(models);
this.saveCache(key, models);
return models;
},
fetchKeyInfo: async function(apiKey) {
var key = (apiKey || '').trim();
if (!key) return null;
try {
var resp = await fetch('https://openrouter.ai/api/v1/key', {
headers: { Authorization: 'Bearer ' + key }
});
if (!resp.ok) return null;
var json = await resp.json();
return json.data || null;
} catch (e) {
return null;
}
},
ensureValidSelection: function(currentId, models, preferredId) {
if (!models || !models.length) return currentId || preferredId || '';
if (currentId && models.some(function(model) { return model.id === currentId; })) {
return currentId;
}
if (preferredId && models.some(function(model) { return model.id === preferredId; })) {
return preferredId;
}
return models[0].id;
}
};
+75 -22
View File
@@ -1,37 +1,90 @@
/**
* Theme registry and application.
* Add entries to `themes` to expose new themes in the UI dropdown.
*/
window.ThemeUtils = {
toggleTheme(currentTheme) {
const newTheme = !currentTheme;
if (newTheme) {
document.body.classList.add('dark-theme');
document.body.classList.remove('light-theme');
} else {
document.body.classList.add('light-theme');
document.body.classList.remove('dark-theme');
defaultTheme: 'dark',
themes: [
{ id: 'dark', name: 'Dark', icon: 'fa-moon' },
{ id: 'light', name: 'Light', icon: 'fa-sun' },
{ id: 'accessible', name: 'Accessible', icon: 'fa-universal-access' },
{ id: 'bt6', name: 'BT6', icon: 'fa-shield-halved' },
{ id: 'pliny', name: 'Pliny', icon: 'fa-dragon' },
{ id: 'cyberpunk', name: 'Cyberpunk', icon: 'fa-city' },
{ id: 'wildwest', name: 'Wild West', icon: 'fa-hat-cowboy' }
],
getThemes() {
return this.themes.slice();
},
isValidTheme(themeId) {
return this.themes.some(function(theme) {
return theme.id === themeId;
});
},
normalizeThemeId(themeId) {
if (this.isValidTheme(themeId)) {
return themeId;
}
return this.defaultTheme;
},
applyTheme(themeId) {
var resolved = this.normalizeThemeId(themeId);
var body = document.body;
this.themes.forEach(function(theme) {
body.classList.remove('theme-' + theme.id);
});
body.classList.remove('dark-theme', 'light-theme');
body.classList.add('theme-' + resolved);
// Legacy aliases used by a few rules / older saved state
if (resolved === 'light' || resolved === 'accessible') {
body.classList.add('light-theme');
} else {
body.classList.add('dark-theme');
}
try {
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
localStorage.setItem('theme', resolved);
} catch (e) {
console.warn('Failed to save theme preference:', e);
}
return newTheme;
return resolved;
},
initializeTheme() {
try {
const saved = localStorage.getItem('theme');
if (saved === 'light') {
return false;
} else if (saved === 'dark') {
return true;
var saved = localStorage.getItem('theme');
if (saved) {
return this.normalizeThemeId(saved);
}
} catch (e) {
console.warn('Failed to load theme preference:', e);
}
return true;
return this.defaultTheme;
},
cycleTheme(currentThemeId) {
var current = this.normalizeThemeId(currentThemeId);
var index = this.themes.findIndex(function(theme) {
return theme.id === current;
});
if (index === -1) {
index = 0;
}
var next = this.themes[(index + 1) % this.themes.length];
return this.applyTheme(next.id);
},
/** Apply saved theme before Vue mounts (call from inline script in index). */
applyInitialTheme() {
return this.applyTheme(this.initializeTheme());
}
};
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "p4rs3lt0ngv3",
"version": "1.0.0",
"version": "4.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "p4rs3lt0ngv3",
"version": "1.0.0",
"version": "4.0.0",
"license": "MIT",
"devDependencies": {
"@zxing/library": "^0.23.0",
@@ -1229,7 +1229,7 @@
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "p4rs3lt0ngv3",
"version": "1.0.0",
"version": "4.0.0",
"description": "Universal Text Encoder/Decoder & Steganography Tool",
"scripts": {
"build:codes-vendor": "node build/build-code-vendor.js",
+1 -6
View File
@@ -56,12 +56,7 @@
</div>
<div class="options-grid ac-options">
<label>
Model
<select v-model="acModel">
<option v-for="m in acModels" :key="m.id" :value="m.id">{{ m.name }} ({{ m.provider }})</option>
</select>
</label>
<openrouter-model-select v-model="acModel" label="Model"></openrouter-model-select>
<label
class="slider-block"
title="Sampling randomness: lower is more deterministic, higher is more varied (OpenRouter 02)."
+16 -8
View File
@@ -52,8 +52,10 @@
</div>
<div class="options-grid bij-options">
<label>
Bijection type
<label class="bij-field bij-field-type">
<span class="bij-field-text">
<span class="bij-field-name">Bijection type</span>
</span>
<select v-model="bijectionType">
<option value="char-to-num">Character → Number</option>
<option value="char-to-symbol">Character → Symbol</option>
@@ -65,19 +67,25 @@
<option value="rot-variant">ROT variant</option>
</select>
</label>
<label>
Fixed size (unchanged leading chars)
<label class="bij-field bij-field-narrow">
<span class="bij-field-text">
<span class="bij-field-name">Fixed size</span>
<small class="bij-field-hint">Unchanged leading chars</small>
</span>
<input type="number" v-model.number="bijectionFixedSize" min="0" max="10" />
</label>
<label>
Attack budget (variants)
<label class="bij-field bij-field-narrow">
<span class="bij-field-text">
<span class="bij-field-name">Attack budget</span>
<small class="bij-field-hint">Prompt variants</small>
</span>
<input type="number" v-model.number="bijectionBudget" min="1" max="50" />
</label>
</div>
<div class="options-grid bij-toggles">
<label class="switch neon"><input type="checkbox" v-model="bijectionIncludeExamples" /><span>Include example conversation</span></label>
<label class="switch neon"><input type="checkbox" v-model="bijectionAutoCopy" /><span>Auto-copy first prompt</span></label>
<label class="switch neon"><input type="checkbox" v-model="bijectionIncludeExamples" /><span>Example conversation</span></label>
<label class="switch neon"><input type="checkbox" v-model="bijectionAutoCopy" /><span>Auto-copy prompt</span></label>
</div>
<div class="tool-toolbar">
+1 -6
View File
@@ -85,12 +85,7 @@
</div>
<div class="options-grid pc-options">
<label>
Model
<select v-model="pcModel">
<option v-for="m in pcModels" :key="m.id" :value="m.id">{{ m.name }} ({{ m.provider }})</option>
</select>
</label>
<openrouter-model-select v-model="pcModel" label="Model"></openrouter-model-select>
<label>
Variants
<input type="number" v-model.number="pcCount" min="1" max="10" />
+1 -8
View File
@@ -63,14 +63,7 @@
Category / theme
<input type="text" v-model="saCategory" placeholder="e.g. nautical, cooking, astronomy" />
</label>
<label v-if="saHasApiKey()">
OpenRouter model
<select v-model="saModel">
<option v-for="model in saModels" :key="model.id" :value="model.id">
{{ model.name }} ({{ model.provider }})
</option>
</select>
</label>
<openrouter-model-select v-if="saHasApiKey()" v-model="saModel" label="OpenRouter model"></openrouter-model-select>
</div>
<div class="sa-generate-row">
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="transform-section">
<div class="section-header-card">
<div class="section-header-card-title">
<i class="fas fa-grip-lines"></i>
<i class="fas fa-scissors"></i>
<h3>Message Splitter</h3>
</div>
<p class="section-header-card-description">Split text into multiple copyable chunks. Each message can be transformed and encapsulated individually.</p>
@@ -161,7 +161,7 @@
<!-- Generate Button -->
<div class="tool-toolbar">
<button type="button" class="transform-button tool-primary-btn" @click="generateSplitMessages">
<i class="fas fa-cut"></i> Split Messages
<i class="fas fa-scissors"></i> Split Messages
</button>
<button class="action-button copy" v-if="splitMessages.length" @click="copyAllSplitMessages">
<i class="fas fa-copy"></i> Copy All
+90 -70
View File
@@ -19,48 +19,62 @@
</span>
</div>
<div class="tokenade-presets">
<button class="transform-button" title="Very light density" @click="applyTokenadePreset('feather')">🪶 Featherweight</button>
<button class="transform-button" title="Light density" @click="applyTokenadePreset('light')">🍃 Lightweight</button>
<button class="transform-button" title="Balanced density" @click="applyTokenadePreset('middle')">🪨 Middleweight</button>
<button class="transform-button" title="High density" @click="applyTokenadePreset('heavy')">🗿 Heavyweight</button>
<button class="transform-button" title="Extreme density (use with care)" @click="applyTokenadePreset('super')">⚓ Super Heavyweight</button>
<button class="transform-button" title="Very light density" @click="applyTokenadePreset('feather')">🪶 Feather</button>
<button class="transform-button" title="Light density" @click="applyTokenadePreset('light')">🍃 Light</button>
<button class="transform-button" title="Balanced density" @click="applyTokenadePreset('middle')">🪨 Middle</button>
<button class="transform-button" title="High density" @click="applyTokenadePreset('heavy')">🗿 Heavy</button>
<button class="transform-button" title="Extreme density (use with care)" @click="applyTokenadePreset('super')">⚓ Super</button>
</div>
<div class="token-bomb-controls options-grid hacker-controls">
<label class="slider-block" title="Nesting levels; higher = more layers of grouping. Tip: Increasing depth/breadth grows size multiplicatively.">
Depth (nesting)
<input type="range" v-model.number="tbDepth" min="1" max="8" />
<span class="slider-value">{{ tbDepth }}</span>
</label>
<label class="slider-block" title="How many items per level; higher = wider structure. Tip: Increasing depth/breadth grows size multiplicatively.">
Breadth per level
<input type="range" v-model.number="tbBreadth" min="1" max="10" />
<span class="slider-value">{{ tbBreadth }}</span>
</label>
<label class="slider-block" title="How many times to repeat the whole block">
Repeats (blocks)
<input type="range" v-model.number="tbRepeats" min="1" max="50" />
<span class="slider-value">{{ tbRepeats }}</span>
</label>
<label class="switch neon" title="Adds VS16/VS15 after glyphs to increase token churn">
<input type="checkbox" v-model="tbIncludeVS" />
<span>Variation Selectors</span>
</label>
<label class="switch neon" title="Sprinkles random zero-width codepoints between segments">
<input type="checkbox" v-model="tbIncludeNoise" />
<span>Invisible Noise</span>
</label>
<!-- Randomize always on by default; control removed from UI -->
<label class="switch neon" title="Automatically copy result after generation">
<input type="checkbox" v-model="tbAutoCopy" />
<span>Auto-copy</span>
</label>
<div class="segmented" title="Invisible separator inserted between units">
<div class="segmented-label">Separator</div>
<div>
<button :class="{active: tbSeparator==='zwj'}" @click="tbSeparator='zwj'">ZWJ</button>
<button :class="{active: tbSeparator==='zwnj'}" @click="tbSeparator='zwnj'">ZWNJ</button>
<button :class="{active: tbSeparator==='zwsp'}" @click="tbSeparator='zwsp'">ZWSP</button>
<button :class="{active: tbSeparator==='none'}" @click="tbSeparator='none'">None</button>
<div class="tokenade-controls">
<div class="options-grid tokenade-sliders">
<label class="slider-block" title="Nesting levels; higher = more layers of grouping. Tip: Increasing depth/breadth grows size multiplicatively.">
<span class="tokenade-field-text">
<span class="tokenade-field-name">Depth</span>
<small class="tokenade-field-hint">Nesting levels</small>
</span>
<input type="range" v-model.number="tbDepth" min="1" max="8" />
<span class="slider-value">{{ tbDepth }}</span>
</label>
<label class="slider-block" title="How many items per level; higher = wider structure. Tip: Increasing depth/breadth grows size multiplicatively.">
<span class="tokenade-field-text">
<span class="tokenade-field-name">Breadth</span>
<small class="tokenade-field-hint">Items per level</small>
</span>
<input type="range" v-model.number="tbBreadth" min="1" max="10" />
<span class="slider-value">{{ tbBreadth }}</span>
</label>
<label class="slider-block" title="How many times to repeat the whole block">
<span class="tokenade-field-text">
<span class="tokenade-field-name">Repeats</span>
<small class="tokenade-field-hint">Block count</small>
</span>
<input type="range" v-model.number="tbRepeats" min="1" max="50" />
<span class="slider-value">{{ tbRepeats }}</span>
</label>
</div>
<div class="options-grid tokenade-toggles">
<label class="switch neon" title="Adds VS16/VS15 after glyphs to increase token churn">
<input type="checkbox" v-model="tbIncludeVS" />
<span>Variation selectors</span>
</label>
<label class="switch neon" title="Sprinkles random zero-width codepoints between segments">
<input type="checkbox" v-model="tbIncludeNoise" />
<span>Invisible noise</span>
</label>
<label class="switch neon" title="Automatically copy result after generation">
<input type="checkbox" v-model="tbAutoCopy" />
<span>Auto-copy</span>
</label>
</div>
<div class="tokenade-separator-row">
<div class="segmented" title="Invisible separator inserted between units">
<div class="segmented-label">Separator</div>
<div class="segmented-buttons">
<button type="button" :class="{active: tbSeparator==='zwj'}" @click="tbSeparator='zwj'">ZWJ</button>
<button type="button" :class="{active: tbSeparator==='zwnj'}" @click="tbSeparator='zwnj'">ZWNJ</button>
<button type="button" :class="{active: tbSeparator==='zwsp'}" @click="tbSeparator='zwsp'">ZWSP</button>
<button type="button" :class="{active: tbSeparator==='none'}" @click="tbSeparator='none'">None</button>
</div>
</div>
</div>
</div>
@@ -69,33 +83,37 @@
Estimated length: {{ estimateTokenadeLength().toLocaleString() }} chars
</small>
</div>
<div class="tokenade-carrier-options options-grid">
<div class="carrier-quick-grid" v-if="tbSingleCarrier">
<small>Quick picks</small>
<div class="emoji-grid">
<button class="emoji-button" v-for="em in quickCarrierEmojis" :key="'q-'+em" @click="tbCarrier = em" :title="'Use '+em">{{ em }}</button>
</div>
</div>
<label class="switch neon" title="Condense payload into a single emoji carrier">
<div class="tokenade-carrier-options">
<label class="switch neon tokenade-carrier-toggle" title="Condense payload into a single emoji carrier">
<input type="checkbox" v-model="tbSingleCarrier" />
<span>Single emoji carrier mode</span>
</label>
<label v-if="tbSingleCarrier">
<span class="label-with-tooltip">
Carrier
<i class="fas fa-info-circle tooltip-icon" data-tooltip="Choose the carrier emoji (quick list)"></i>
</span>
<select v-model="tbCarrier" @focus="setCarrierFromSelected">
<option v-for="(em, index) in carrierEmojiList" :key="'carrier-'+index" :value="em">{{ em }}</option>
</select>
</label>
<label v-if="tbSingleCarrier">
<span class="label-with-tooltip">
Advanced: custom carrier text
<i class="fas fa-info-circle tooltip-icon" data-tooltip="When set, this overrides quick picks and dropdown."></i>
</span>
<input type="text" v-model="tbCarrierManual" placeholder="e.g., ⚙️ or ::tag::" />
<span>Single emoji carrier</span>
</label>
<template v-if="tbSingleCarrier">
<div class="carrier-quick-grid">
<small>Quick picks</small>
<div class="emoji-grid">
<button class="emoji-button" v-for="em in quickCarrierEmojis" :key="'q-'+em" @click="tbCarrier = em" :title="'Use '+em">{{ em }}</button>
</div>
</div>
<div class="options-grid tokenade-carrier-fields">
<label>
<span class="label-with-tooltip">
Carrier
<i class="fas fa-info-circle tooltip-icon" data-tooltip="Choose the carrier emoji (quick list)"></i>
</span>
<select v-model="tbCarrier" @focus="setCarrierFromSelected">
<option v-for="(em, index) in carrierEmojiList" :key="'carrier-'+index" :value="em">{{ em }}</option>
</select>
</label>
<label>
<span class="tokenade-field-text">
<span class="tokenade-field-name">Custom carrier</span>
<small class="tokenade-field-hint">Overrides quick picks</small>
</span>
<input type="text" v-model="tbCarrierManual" placeholder="e.g., ⚙️ or ::tag::" />
</label>
</div>
</template>
</div>
<div class="tool-toolbar">
<button type="button" class="transform-button tool-primary-btn" @click="generateTokenBomb" title="Build the tokenade with current settings">
@@ -122,18 +140,20 @@
Base text
<input type="text" v-model="tpBase" placeholder="Enter base snippet (e.g., hello)" />
</label>
<div class="options-grid">
<div class="options-grid tokenade-text-options">
<label>
Repeat count
<span class="tokenade-field-text">
<span class="tokenade-field-name">Repeat count</span>
</span>
<input type="number" v-model.number="tpRepeat" min="1" max="1000" />
</label>
<label class="switch neon">
<input type="checkbox" v-model="tpCombining" />
<span>Add combining marks</span>
<span>Combining marks</span>
</label>
<label class="switch neon">
<input type="checkbox" v-model="tpZW" />
<span>Add zero-width spacing</span>
<span>Zero-width spacing</span>
</label>
</div>
<div class="tool-toolbar">
+176 -53
View File
@@ -89,7 +89,7 @@
:class="{ active: activeTransform === item.transform }"
:title="'Click to transform and copy: ' + item.transform.name"
>
{{ item.transform.name }}
<span class="transform-button-name">{{ item.transform.name }}</span>
<small class="transform-preview" v-if="transformInput">
{{ item.transform.preview(transformInput.slice(0, 10), getMergedOptionsForTransform(item.transform.name)) }}
</small>
@@ -106,6 +106,23 @@
:title="isFavorite(item.transform.name) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<button
v-if="transformHasOptionsUI(item.transform)"
type="button"
class="transform-card-action transform-card-action-options"
@click.stop="openTransformOptions(item.transform, $event)"
:aria-label="'Options for ' + item.transform.name"
>Options</button>
<span v-else class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isFavorite(item.transform.name) }"
@click.stop="toggleFavorite(item.transform.name, $event)"
:aria-pressed="isFavorite(item.transform.name) ? 'true' : 'false'"
>{{ isFavorite(item.transform.name) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
<div v-else-if="item.type === 'translate'" class="transform-button-group">
<button
@@ -115,7 +132,7 @@
:disabled="translateLoading"
:title="'Translate to ' + item.langName"
>
{{ item.langName }}
<span class="transform-button-name">{{ item.langName }}</span>
<small class="transform-preview" v-if="transformInput">
{{ transformInput.slice(0, 10) }}
</small>
@@ -126,6 +143,16 @@
:title="isTranslateFavorite(item.langName, item.custom) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<span class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isTranslateFavorite(item.langName, item.custom) }"
@click.stop="toggleTranslateFavorite(item.langName, item.custom, $event)"
:aria-pressed="isTranslateFavorite(item.langName, item.custom) ? 'true' : 'false'"
>{{ isTranslateFavorite(item.langName, item.custom) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</template>
</div>
@@ -149,7 +176,7 @@
:class="{ active: activeTransform === item.transform }"
:title="'Click to transform and copy: ' + item.transform.name"
>
{{ item.transform.name }}
<span class="transform-button-name">{{ item.transform.name }}</span>
<small class="transform-preview" v-if="transformInput">
{{ item.transform.preview(transformInput.slice(0, 10), getMergedOptionsForTransform(item.transform.name)) }}
</small>
@@ -166,6 +193,23 @@
:title="isFavorite(item.transform.name) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<button
v-if="transformHasOptionsUI(item.transform)"
type="button"
class="transform-card-action transform-card-action-options"
@click.stop="openTransformOptions(item.transform, $event)"
:aria-label="'Options for ' + item.transform.name"
>Options</button>
<span v-else class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isFavorite(item.transform.name) }"
@click.stop="toggleFavorite(item.transform.name, $event)"
:aria-pressed="isFavorite(item.transform.name) ? 'true' : 'false'"
>{{ isFavorite(item.transform.name) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
<div v-else-if="item.type === 'translate'" class="transform-button-group">
<button
@@ -175,7 +219,7 @@
:disabled="translateLoading"
:title="'Translate to ' + item.langName"
>
{{ item.langName }}
<span class="transform-button-name">{{ item.langName }}</span>
<small class="transform-preview" v-if="transformInput">
{{ transformInput.slice(0, 10) }}
</small>
@@ -186,6 +230,16 @@
:title="isTranslateFavorite(item.langName, item.custom) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<span class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isTranslateFavorite(item.langName, item.custom) }"
@click.stop="toggleTranslateFavorite(item.langName, item.custom, $event)"
:aria-pressed="isTranslateFavorite(item.langName, item.custom) ? 'true' : 'false'"
>{{ isTranslateFavorite(item.langName, item.custom) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</template>
</div>
@@ -206,55 +260,79 @@
<i class="fas fa-spinner fa-spin"></i> Translating to {{ translateActiveLang }}...
</div>
<div class="translate-model-picker">
<select v-model="translateModel" class="translate-model-select">
<option v-for="m in translateModels" :key="m.id" :value="m.id">{{ m.name }} — {{ m.note }}</option>
</select>
</div>
<openrouter-model-select v-model="translateModel" label="Translation model"></openrouter-model-select>
<div class="translate-subsection">
<div class="translate-subsection-label"><i class="fas fa-globe"></i> Major</div>
<div class="translate-lang-grid translate-lang-grid-inline">
<button
<div
v-for="lang in translateMainLangs"
:key="lang.code"
class="translate-lang-btn translate-lang-main"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
class="translate-lang-card"
>
<span class="translate-flag">{{ translateGetFlag(lang.flag) }}</span>
<span class="translate-name">{{ lang.name }}</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, false) }"
:title="isTranslateFavorite(lang.name, false) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<button
class="translate-lang-btn translate-lang-main"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
>
<span class="translate-flag">{{ translateGetFlag(lang.flag) }}</span>
<span class="translate-name">{{ lang.name }}</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, false) }"
:title="isTranslateFavorite(lang.name, false) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<span class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isTranslateFavorite(lang.name, false) }"
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
:aria-pressed="isTranslateFavorite(lang.name, false) ? 'true' : 'false'"
>{{ isTranslateFavorite(lang.name, false) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</div>
</div>
<div class="translate-subsection">
<div class="translate-subsection-label"><i class="fas fa-scroll"></i> Dead &amp; Exotic</div>
<div class="translate-lang-grid translate-lang-grid-inline">
<button
<div
v-for="lang in translateExoticLangs"
:key="lang.code"
class="translate-lang-btn translate-lang-exotic"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
class="translate-lang-card"
>
<span class="translate-flag">{{ translateGetFlag(lang.flag) }}</span>
<span class="translate-name">{{ lang.name }}</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, false) }"
:title="isTranslateFavorite(lang.name, false) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<button
class="translate-lang-btn translate-lang-exotic"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
>
<span class="translate-flag">{{ translateGetFlag(lang.flag) }}</span>
<span class="translate-name">{{ lang.name }}</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, false) }"
:title="isTranslateFavorite(lang.name, false) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<span class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isTranslateFavorite(lang.name, false) }"
@click.stop="toggleTranslateFavorite(lang.name, false, $event)"
:aria-pressed="isTranslateFavorite(lang.name, false) ? 'true' : 'false'"
>{{ isTranslateFavorite(lang.name, false) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</div>
</div>
@@ -283,24 +361,42 @@
</button>
</div>
<div class="translate-lang-grid translate-lang-grid-inline" v-if="translateCustomLangs.length">
<button
<div
v-for="(lang, idx) in translateCustomLangs"
:key="'custom-'+idx"
class="translate-lang-btn translate-lang-custom"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
class="translate-lang-card"
>
<span class="translate-flag translate-custom-flag" aria-hidden="true"><i class="fas fa-globe"></i></span>
<span class="translate-name">{{ lang.name }}</span>
<span class="translate-remove" @click.stop="translateRemoveCustomLang(idx)" title="Remove">&times;</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, true, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, true) }"
:title="isTranslateFavorite(lang.name, true) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<button
class="translate-lang-btn translate-lang-custom"
@click="translateTo(lang.name)"
:disabled="translateLoading"
:title="'Translate to ' + lang.name"
>
<span class="translate-flag translate-custom-flag" aria-hidden="true"><i class="fas fa-globe"></i></span>
<span class="translate-name">{{ lang.name }}</span>
<span class="translate-remove" @click.stop="translateRemoveCustomLang(idx)" title="Remove">&times;</span>
<i
@click.stop="toggleTranslateFavorite(lang.name, true, $event)"
class="fas fa-star favorite-icon translate-lang-favorite"
:class="{ favorited: isTranslateFavorite(lang.name, true) }"
:title="isTranslateFavorite(lang.name, true) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<button
type="button"
class="transform-card-action translate-lang-action-remove"
@click.stop="translateRemoveCustomLang(idx)"
>Remove</button>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isTranslateFavorite(lang.name, true) }"
@click.stop="toggleTranslateFavorite(lang.name, true, $event)"
:aria-pressed="isTranslateFavorite(lang.name, true) ? 'true' : 'false'"
>{{ isTranslateFavorite(lang.name, true) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</div>
<div v-if="!translateCustomLangs.length && !translateAddingLang" class="translate-empty-custom">
<small>Click <strong>Custom</strong> above to add any language.</small>
@@ -338,6 +434,16 @@
:title="isFavorite(transform.name) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<span class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isFavorite(transform.name) }"
@click.stop="toggleFavorite(transform.name, $event)"
:aria-pressed="isFavorite(transform.name) ? 'true' : 'false'"
>{{ isFavorite(transform.name) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</div>
<div class="randomizer-info">
@@ -387,7 +493,7 @@
:class="{ active: activeTransform === transform }"
:title="'Click to transform and copy: ' + transform.name"
>
{{ transform.name }}
<span class="transform-button-name">{{ transform.name }}</span>
<small class="transform-preview" v-if="transformInput">
{{ transform.preview(transformInput.slice(0, 10), getMergedOptionsForTransform(transform.name)) }}
</small>
@@ -404,6 +510,23 @@
:title="isFavorite(transform.name) ? 'Remove from favorites' : 'Add to favorites'"
></i>
</button>
<div class="transform-card-actions">
<button
v-if="transformHasOptionsUI(transform)"
type="button"
class="transform-card-action transform-card-action-options"
@click.stop="openTransformOptions(transform, $event)"
:aria-label="'Options for ' + transform.name"
>Options</button>
<span v-else class="transform-card-action-slot" aria-hidden="true"></span>
<button
type="button"
class="transform-card-action transform-card-action-favorite"
:class="{ 'is-favorited': isFavorite(transform.name) }"
@click.stop="toggleFavorite(transform.name, $event)"
:aria-pressed="isFavorite(transform.name) ? 'true' : 'false'"
>{{ isFavorite(transform.name) ? 'Favorited' : 'Favorite' }}</button>
</div>
</div>
</div>
</div>