Add share button

This commit is contained in:
Joey Yakimowich-Payne 2025-08-11 08:14:35 -06:00
commit 80a1b2470f
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
2 changed files with 564 additions and 8 deletions

View file

@ -17,6 +17,21 @@
<!-- Main Content Area (Board and Controls) -->
<div class="w-full flex flex-col items-center justify-center p-4 overflow-y-auto">
<!-- Top-right action buttons -->
<div class="fixed top-4 right-4 z-30 flex gap-2">
<button onclick="copyShareLink()" class="bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-full shadow-lg" title="Copy Share Link" aria-label="Copy Share Link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M13.5 6a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0V8.56l-6.22 6.22a.75.75 0 0 1-1.06-1.06L16.94 7.5h-1.94A.75.75 0 0 1 13.5 6Z"/>
<path d="M6 5.25A2.25 2.25 0 0 0 3.75 7.5v12A2.25 2.25 0 0 0 6 21.75h12A2.25 2.25 0 0 0 20.25 19.5v-6a.75.75 0 0 0-1.5 0v6c0 .414-.336.75-.75.75H6a.75.75 0 0 1-.75-.75v-12c0-.414.336-.75.75-.75h6a.75.75 0 0 0 0-1.5H6Z"/>
</svg>
</button>
<button id="config-toggle-button" onclick="toggleConfig()" class="bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-full shadow-lg" title="Toggle Settings" aria-label="Toggle Settings">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.646.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.004.827c-.293.24-.438.613-.431.992a6.759 6.759 0 0 1 0 1.555c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 0 1-.22.128c-.333.184-.583.496-.646.87l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.646-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.293-.24.438-.613.431-.992a6.759 6.759 0 0 1 0-1.555c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.184.582-.496.646-.87l.213-1.281Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
<!-- Search Bar -->
<div class="mb-4 w-full max-w-xl">
<label for="search-input" class="sr-only">Search Items</label>
@ -51,12 +66,6 @@
<button onclick="clearMarks()" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
Clear Marks
</button>
<button id="config-toggle-button" onclick="toggleConfig()" class="fixed top-4 right-4 z-30 bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-full shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.646.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.004.827c-.293.24-.438.613-.431.992a6.759 6.759 0 0 1 0 1.555c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 0 1-.22.128c-.333.184-.583.496-.646.87l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.646-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.293-.24.438-.613.431-.992a6.759 6.759 0 0 1 0-1.555c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.184.582-.496.646-.87l.213-1.281Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
</div>
@ -441,7 +450,7 @@
<script type="module" src="js/beango.js?v=1.0.4"></script>
<script type="module">
import { toggleConfig, exportSettings, importSettings, resetAllSettings, explodeBeans, randomizeBoard, clearMarks, generateBoard, resetSettings } from './js/beango.js';
import { toggleConfig, exportSettings, importSettings, resetAllSettings, explodeBeans, randomizeBoard, clearMarks, generateBoard, resetSettings, copyShareLink } from './js/beango.js';
window.explodeBeans = explodeBeans;
window.toggleConfig = toggleConfig;
window.exportSettings = exportSettings;
@ -451,6 +460,7 @@
window.clearMarks = clearMarks;
window.generateBoard = generateBoard;
window.resetSettings = resetSettings;
window.copyShareLink = copyShareLink;
</script>
</body>
</html>

View file

@ -1392,6 +1392,9 @@ export function explodeBeans(event) {
// --- Consolidated Event Listeners ---
document.addEventListener('DOMContentLoaded', () => {
// Apply shared settings from URL if present before loading
tryApplySettingsFromUrl();
// Load state first thing
loadFromLocalStorage();
@ -1600,7 +1603,550 @@ function checkWinConditions() {
}
// --- Settings Import/Export ---
// --- Settings Import/Export & Sharing ---
// v1 fallback: base64url helpers (kept for backward compatibility)
function base64UrlEncode(text) {
const utf8 = new TextEncoder().encode(text);
let binary = '';
utf8.forEach(byte => binary += String.fromCharCode(byte));
const base64 = btoa(binary);
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlDecode(text) {
let base64 = text.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
// v2: Lightweight LZ-based compression for URI component
// Adapted from LZ-String (MIT) compressToEncodedURIComponent/decompressFromEncodedURIComponent
const LZ = (function() {
const f = String.fromCharCode;
const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
const baseReverseDic = {};
function getBaseValue(alphabet, character) {
if (!baseReverseDic[alphabet]) {
baseReverseDic[alphabet] = {};
for (let i = 0; i < alphabet.length; i++) {
baseReverseDic[alphabet][alphabet.charAt(i)] = i;
}
}
return baseReverseDic[alphabet][character];
}
function compressToEncodedURIComponent(input) {
if (input == null) return '';
return _compress(input, 6, function(a){ return keyStrUriSafe.charAt(a); });
}
function _compress(uncompressed, bitsPerChar, getCharFromInt) {
if (uncompressed == null) return '';
let i, value,
context_dictionary = {},
context_dictionaryToCreate = {},
context_c = '',
context_wc = '',
context_w = '',
context_enlargeIn = 2, // Compensate for the first entry which should not count
context_dictSize = 3,
context_numBits = 2,
context_data = [],
context_data_val = 0,
context_data_position = 0,
ii;
for (ii = 0; ii < uncompressed.length; ii += 1) {
context_c = uncompressed.charAt(ii);
if (!Object.prototype.hasOwnProperty.call(context_dictionary, context_c)) {
context_dictionary[context_c] = context_dictSize++;
context_dictionaryToCreate[context_c] = true;
}
context_wc = context_w + context_c;
if (Object.prototype.hasOwnProperty.call(context_dictionary, context_wc)) {
context_w = context_wc;
} else {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
// Add wc to the dictionary.
context_dictionary[context_wc] = context_dictSize++;
context_w = String(context_c);
}
}
// Output the code for w.
if (context_w !== '') {
if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
if (context_w.charCodeAt(0) < 256) {
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
}
value = context_w.charCodeAt(0);
for (i = 0; i < 8; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
} else {
value = 1;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | value;
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = 0;
}
value = context_w.charCodeAt(0);
for (i = 0; i < 16; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
delete context_dictionaryToCreate[context_w];
} else {
value = context_dictionary[context_w];
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
}
context_enlargeIn--;
if (context_enlargeIn == 0) {
context_enlargeIn = Math.pow(2, context_numBits);
context_numBits++;
}
}
// Mark the end of the stream
value = 2;
for (i = 0; i < context_numBits; i++) {
context_data_val = (context_data_val << 1) | (value & 1);
if (context_data_position == bitsPerChar - 1) {
context_data_position = 0;
context_data.push(getCharFromInt(context_data_val));
context_data_val = 0;
} else {
context_data_position++;
}
value = value >> 1;
}
// Flush the last char
while (true) {
context_data_val = (context_data_val << 1);
if (context_data_position == bitsPerChar - 1) {
context_data.push(getCharFromInt(context_data_val));
break;
} else {
context_data_position++;
}
}
return context_data.join('');
}
function decompressFromEncodedURIComponent(input) {
if (input == null) return '';
if (input === '') return null;
input = input.replace(/ /g, '+');
return _decompress(input.length, 32, function(index) { return getBaseValue(keyStrUriSafe, input.charAt(index)); });
}
function _decompress(length, resetValue, getNextValue) {
let dictionary = []
, next
, enlargeIn = 4
, dictSize = 4
, numBits = 3
, entry = ''
, result = []
, i
, w
, bits, resb, maxpower, power
, c
, data = {val:getNextValue(0), position:resetValue, index:1};
for (i = 0; i < 3; i += 1) {
dictionary[i] = i;
}
bits = 0;
maxpower = Math.pow(2, 2);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
switch (next = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
c = f(bits);
break;
case 2:
return '';
}
dictionary[3] = c;
w = c;
result.push(c);
while (true) {
if (data.index > length) {
return '';
}
bits = 0;
maxpower = Math.pow(2, numBits);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
switch (c = bits) {
case 0:
bits = 0;
maxpower = Math.pow(2, 8);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 1:
bits = 0;
maxpower = Math.pow(2, 16);
power = 1;
while (power != maxpower) {
resb = data.val & data.position;
data.position >>= 1;
if (data.position == 0) {
data.position = resetValue;
data.val = getNextValue(data.index++);
}
bits |= (resb>0 ? 1 : 0) * power;
power <<= 1;
}
dictionary[dictSize++] = f(bits);
c = dictSize - 1;
enlargeIn--;
break;
case 2:
return result.join('');
}
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
if (dictionary[c]) {
entry = dictionary[c];
} else {
if (c === dictSize) {
entry = w + w.charAt(0);
} else {
return '';
}
}
result.push(entry);
// Add w+entry[0]
dictionary[dictSize++] = w + entry.charAt(0);
enlargeIn--;
w = entry;
if (enlargeIn == 0) {
enlargeIn = Math.pow(2, numBits);
numBits++;
}
}
}
return {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
};
})();
function collectAllBeangoSettings() {
const settings = {};
const prefix = 'beango_';
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
settings[key] = localStorage.getItem(key);
}
}
return settings;
}
export async function copyShareLink() {
try {
const data = collectAllBeangoSettings();
const json = JSON.stringify(data);
// v2 compressed, URL-safe
const encoded = LZ.compressToEncodedURIComponent(json);
const shareUrl = `${location.origin}${location.pathname}#s=v2:${encoded}`;
let copySucceeded = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
copySucceeded = true;
}
} catch {
// Fall through to legacy copy
}
if (!copySucceeded) {
const tempInput = document.createElement('input');
tempInput.value = shareUrl;
tempInput.style.position = 'fixed';
tempInput.style.top = '-1000px';
document.body.appendChild(tempInput);
tempInput.focus();
tempInput.select();
try {
copySucceeded = document.execCommand('copy');
} catch {
copySucceeded = false;
}
document.body.removeChild(tempInput);
}
if (copySucceeded) {
showNotification('Share link copied to clipboard!', 'success');
} else {
try { window.prompt('Copy this share link:', shareUrl); } catch { /* ignore */ }
showNotification('Could not copy automatically. Link shown for manual copy.', 'warning');
}
} catch (e) {
console.error('Failed to create share link', e);
showNotification('Failed to create share link.', 'error');
}
}
let _sharedSettingsAppliedOnce = false;
function tryApplySettingsFromUrl() {
try {
const hash = window.location.hash || '';
const match = hash.match(/^#(?:s|settings)=(.+)$/);
if (!match) return;
const payload = match[1];
let json = '';
if (payload.startsWith('v2:')) {
const body = payload.slice(3);
json = LZ.decompressFromEncodedURIComponent(body) || '';
} else {
// v1 fallback (base64url)
json = base64UrlDecode(payload);
}
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null) return;
// Clear existing beango_ keys to avoid stale settings
const prefix = 'beango_';
const toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) toRemove.push(key);
}
toRemove.forEach(k => localStorage.removeItem(k));
let imported = 0;
Object.keys(parsed).forEach(key => {
if (key.startsWith(prefix)) {
const value = parsed[key];
if (value !== undefined && value !== null) {
localStorage.setItem(key, String(value));
imported++;
}
}
});
if (imported > 0) {
_sharedSettingsAppliedOnce = true;
// Clean the URL hash and reload once to ensure all UI reflects new settings
if (history && history.replaceState) {
history.replaceState(null, '', `${location.pathname}${location.search}`);
} else {
window.location.hash = '';
}
// Reload synchronously; after reload the hash is clean, so this runs only once
location.reload();
return;
}
} catch (e) {
console.warn('Failed to apply settings from URL', e);
showNotification('Invalid share link.', 'warning');
}
}
export function exportSettings() {
const settingsToExport = {};
const prefix = 'beango_';