Finished cpp impl and made web version

This commit is contained in:
Asher 2025-07-17 16:51:19 +03:00
commit 868900e6d5
12 changed files with 10148 additions and 0 deletions

24
web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

29
web/eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

15
web/index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeckCrypt 🃏</title>
</head>
<body>
<div id="root"></div>
<script src="/libs/cardcode.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3369
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
web/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.0.4"
}
}

6274
web/public/libs/cardcode.js Normal file

File diff suppressed because it is too large Load Diff

BIN
web/public/libs/cardcode.wasm Executable file

Binary file not shown.

336
web/src/App.jsx Normal file
View File

@ -0,0 +1,336 @@
import { useState, useEffect, useCallback } from 'react';
import Card from './components/Card';
const defaultDeck = Array.from({ length: 52 }, (_, i) => i);
const allowedChars = " .,-\"/abcdefghijklmnopqrstuvwxyz";
function sanitizeInput(value) {
return value
.toLowerCase()
.split('')
.filter((c) => allowedChars.includes(c))
.join('')
.slice(0, 45);
}
function App() {
const [mode, setMode] = useState('encode');
// encode states
const [text, setText] = useState('');
const [deck, setDeck] = useState([]);
// decode states
const [order, setOrder] = useState(Array.from({ length: 52 }, (_, i) => i));
const [decodedText, setDecodedText] = useState('');
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
// encryption
const [useEncryption, setUseEncryption] = useState(false);
const [key, setKey] = useState('');
// misc
const [elapsedMs, setElapsedMs] = useState(null);
const [wasm, setWasm] = useState(null);
// Load the WASM module once on mount
useEffect(() => {
if (!window.createModule) return;
window.createModule().then((m) => setWasm(m));
}, []);
// ENCODE: compute deck when text/key changes
useEffect(() => {
if (mode !== 'encode' || !wasm) return;
const sanitizedText = sanitizeInput(text);
if (sanitizedText !== text) {
setText(sanitizedText);
return;
}
if (useEncryption && key.trim() === '') {
setDeck(defaultDeck);
return;
}
const sanitizedKey = sanitizeInput(key);
// If no text, simply use default deck without calling WASM to avoid errors
if (sanitizedText.length === 0) {
setDeck(defaultDeck);
return;
}
const start = performance.now();
let wasmResult;
try {
wasmResult = useEncryption
? wasm.textToPackOfCardsEncrypted(sanitizedText, sanitizedKey)
: wasm.textToPackOfCards(sanitizedText);
} catch {
// fallback to default deck on any error
wasmResult = defaultDeck;
}
const jsArray = Array.from(wasmResult);
if (jsArray.length !== 52) {
setDeck(defaultDeck);
} else {
setDeck(jsArray);
}
setElapsedMs(performance.now() - start);
}, [mode, text, key, useEncryption, wasm]);
// DECODE: compute text when order/key changes
useEffect(() => {
if (mode !== 'decode' || !wasm) return;
const sanitizedKey = sanitizeInput(key);
const start = performance.now();
const deckArray = Int32Array.from(order.length === 52 ? order : defaultDeck);
let result;
if (useEncryption) {
console.log("encrypted", deckArray, sanitizedKey);
result = wasm.packOfCardsToTextEncrypted(deckArray, sanitizedKey);
} else {
console.log("unencrypted", deckArray);
result = wasm.packOfCardsToText(deckArray);
}
setElapsedMs(performance.now() - start);
setDecodedText(result);
}, [mode, order, key, useEncryption, wasm]);
const handleChange = (e) => {
setText(sanitizeInput(e.target.value));
};
const handleKeyChange = (e) => {
setKey(sanitizeInput(e.target.value));
};
// drag handlers for decode reorder
const handleDrop = useCallback(
(targetIdx) => {
setOrder((prev) => {
if (dragIdx === null || dragIdx === targetIdx) return prev;
const newOrder = [...prev];
// Remove the dragged item from its current position
const draggedItem = newOrder.splice(dragIdx, 1)[0];
// Insert it at the target position
newOrder.splice(targetIdx, 0, draggedItem);
return newOrder;
});
setDragIdx(null);
setOverIdx(null);
},
[dragIdx]
);
const resetDecode = () => {
setOrder(Array.from({ length: 52 }, (_, i) => i));
setDecodedText('');
setElapsedMs(null);
};
return (
<div className="min-h-screen bg-[#1e1e2e] text-[#cdd6f4] p-4 flex flex-col">
{/* Header with attribution */}
<header className="mb-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-bold text-[#89b4fa] text-center sm:text-left">DeckCrypt 🃏</h1>
<div className="text-sm text-[#a6adc8] text-center sm:text-right">
<p>Made by <span className="text-[#89b4fa] font-medium">Asher Falcon</span></p>
<p>
<a
href="#"
className="text-[#74c7ec] hover:text-[#89dceb] underline transition-colors"
>
Read the blog post to learn how it works
</a>
</p>
</div>
</div>
{/* Mode Toggle - Segmented Control */}
<div className="mt-6 flex justify-center">
<div className="relative bg-[#313244] p-1 rounded-lg inline-flex">
<button
onClick={() => setMode('encode')}
className={`
relative px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 min-w-[80px]
${mode === 'encode'
? 'bg-[#89b4fa] text-[#1e1e2e] shadow-lg'
: 'text-[#bac2de] hover:text-[#cdd6f4] hover:bg-[#45475a]'
}
`}
>
Encode
</button>
<button
onClick={() => setMode('decode')}
className={`
relative px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 min-w-[80px]
${mode === 'decode'
? 'bg-[#89b4fa] text-[#1e1e2e] shadow-lg'
: 'text-[#bac2de] hover:text-[#cdd6f4] hover:bg-[#45475a]'
}
`}
>
Decode
</button>
</div>
</div>
</header>
{/* Main content area */}
<div className="flex-grow overflow-y-auto">
{/* encryption toggle & key */}
<div className="mb-6 p-4 bg-[#313244] rounded-lg border border-[#45475a]">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<div className="relative">
<input
type="checkbox"
checked={useEncryption}
onChange={(e) => setUseEncryption(e.target.checked)}
className="sr-only"
/>
<div className={`
w-11 h-6 rounded-full transition-colors duration-200
${useEncryption ? 'bg-[#89b4fa]' : 'bg-[#585b70]'}
`}>
<div className={`
w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-200
${useEncryption ? 'translate-x-5' : 'translate-x-0'}
mt-0.5 ml-0.5
`} />
</div>
</div>
<span className="text-[#cdd6f4] font-medium">Use encryption key</span>
</label>
{useEncryption && (
<input
type="text"
value={key}
onChange={handleKeyChange}
placeholder="Enter encryption key"
className="flex-1 max-w-xs p-2 bg-[#45475a] border border-[#585b70] rounded-md text-[#cdd6f4] placeholder-[#9399b2] focus:border-[#89b4fa] focus:ring-1 focus:ring-[#89b4fa] focus:outline-none transition-colors"
/>
)}
</div>
</div>
{/* MODE: ENCODE */}
{mode === 'encode' && (
<>
<div className="mb-6">
<input
type="text"
value={text}
onChange={handleChange}
placeholder="Type here (max 45 chars)"
className="w-full max-w-xl p-3 bg-[#45475a] border border-[#585b70] rounded-lg text-[#cdd6f4] placeholder-[#9399b2] focus:border-[#89b4fa] focus:ring-2 focus:ring-[#89b4fa] focus:ring-opacity-50 focus:outline-none transition-all"
/>
<p className="mt-2 text-sm text-[#a6adc8]">
Allowed characters: space, period, comma, hyphen, double quote, slash, a-z.
</p>
</div>
<div className="bg-[#313244] p-4 rounded-lg border border-[#45475a]">
<h3 className="text-lg font-semibold text-[#89b4fa] mb-3">Card Deck Output</h3>
<div className="flex flex-wrap gap-1 max-w-full">
{deck.map((idx, i) => (
<Card key={i} index={idx} />
))}
</div>
</div>
</>
)}
{/* MODE: DECODE */}
{mode === 'decode' && (
<>
<div className="mb-4 flex items-center gap-2">
<button
onClick={resetDecode}
className="px-4 py-2 bg-[#585b70] hover:bg-[#6c7086] text-[#cdd6f4] rounded-lg transition-colors font-medium"
>
Reset order
</button>
</div>
<div className="bg-[#313244] p-4 rounded-lg border border-[#45475a] mb-6">
<h3 className="text-lg font-semibold text-[#89b4fa] mb-3">Drag Cards to Reorder</h3>
{/* draggable card grid */}
<div className="flex flex-wrap gap-1 max-w-full">
{order.map((cardIdx, gridIdx) => (
<Card
key={gridIdx}
index={cardIdx}
draggable
onDragStart={() => setDragIdx(gridIdx)}
onDragOver={(e) => {
e.preventDefault();
setOverIdx(gridIdx);
}}
onDrop={() => handleDrop(gridIdx)}
onDragLeave={() => setOverIdx(null)}
dropTarget={overIdx === gridIdx && dragIdx !== gridIdx}
/>
))}
</div>
</div>
{/* decoded output */}
{decodedText && (
<div className="bg-[#313244] border border-[#45475a] rounded-lg p-4">
<h3 className="text-lg font-semibold text-[#89b4fa] mb-3">Decoded Text</h3>
<div className="p-3 bg-[#45475a] border border-[#585b70] rounded-md text-[#cdd6f4] whitespace-pre-wrap font-mono">
{decodedText}
</div>
</div>
)}
</>
)}
{/* Performance info */}
{elapsedMs !== null && (
<div className="mt-8 text-center text-sm text-[#9399b2]">
Last conversion took {elapsedMs.toFixed(1)} ms
</div>
)}
</div>
{/* Credits section */}
<footer className="mt-6 pt-6 border-t border-[#45475a] text-center">
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col sm:flex-row sm:justify-center gap-4">
<p className="text-xs text-[#a6adc8]">
Styled with <a href="https://catppuccin.com" className="text-[#f38ba8] hover:text-[#eba0ac] transition-colors" target="_blank" rel="noopener noreferrer">Catppuccin</a> theme
</p>
<p className="text-xs text-[#a6adc8]">
Uses <a href="https://github.com/983" className="text-[#f9e2af] hover:text-[#f5e0a3] transition-colors" target="_blank" rel="noopener noreferrer">983</a>'s bignum library
</p>
</div>
<a
href="#"
className="inline-flex items-center justify-center gap-2 text-[#a6adc8] hover:text-[#cdd6f4] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
View on GitHub
</a>
</div>
</footer>
</div>
);
}
export default App;

View File

@ -0,0 +1,50 @@
import React from 'react';
const cardList = [
'A♠', '2♠', '3♠', '4♠', '5♠', '6♠', '7♠', '8♠', '9♠', '10♠', 'J♠', 'Q♠', 'K♠',
'A♥', '2♥', '3♥', '4♥', '5♥', '6♥', '7♥', '8♥', '9♥', '10♥', 'J♥', 'Q♥', 'K♥',
'A♦', '2♦', '3♦', '4♦', '5♦', '6♦', '7♦', '8♦', '9♦', '10♦', 'J♦', 'Q♦', 'K♦',
'A♣', '2♣', '3♣', '4♣', '5♣', '6♣', '7♣', '8♣', '9♣', '10♣', 'J♣', 'Q♣', 'K♣',
];
function getCardInfo(index) {
const str = cardList[index];
// Rank could be 1 or 2 characters (A vs 10)
const suit = str.slice(-1);
const rank = str.slice(0, -1);
return { rank, suit };
}
function Card({ index, onClick, selected = false, disabled = false, dropTarget = false, ...rest }) {
const { rank, suit } = getCardInfo(index);
const isRed = suit === '♥' || suit === '♦';
const baseClasses =
'relative w-12 h-16 m-1 bg-white border rounded-lg shadow text-xs font-bold flex flex-col justify-between p-1 select-none';
const colorClass = isRed ? 'text-red-600' : 'text-black';
const selectedClass = selected ? 'ring-2 ring-blue-500' : '';
const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer hover:shadow-lg';
return (
<div
className={`${baseClasses} ${selectedClass} ${disabledClass}`}
onClick={disabled ? undefined : onClick}
{...rest}
>
{dropTarget && (
<div className="absolute -left-1 top-0 h-full w-1 bg-blue-500"></div>
)}
<span className={colorClass}>
{rank}
{suit}
</span>
<span className={`self-end rotate-180 ${colorClass}`}>
{rank}
{suit}
</span>
</div>
);
}
export { cardList };
export default Card;

1
web/src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

13
web/src/main.jsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<div className="h-screen bg-[#24273a">
<StrictMode>
<App />
</StrictMode>
</div>
)

8
web/vite.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})