From 716f1fab54ef6c6b3a70f9cd4b2b70f7e9cdd7a8 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Wed, 5 Feb 2025 19:37:58 -0800 Subject: Initial commit --- static/index.js | 506 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ static/styles.css | 245 ++++++++++++++++++++++++++ 2 files changed, 751 insertions(+) create mode 100644 static/index.js create mode 100644 static/styles.css (limited to 'static') diff --git a/static/index.js b/static/index.js new file mode 100644 index 0000000..bc850e8 --- /dev/null +++ b/static/index.js @@ -0,0 +1,506 @@ +/* + Offline Tierlist Maker + Copyright (C) 2022 silverweed + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. +*/ + +'use strict'; + +const MAX_NAME_LEN = 200; +const DEFAULT_TIERS = ['S','A','B','C','D','E','F']; +const TIER_COLORS = [ + // from S to F + '#ff6666', + '#f0a731', + '#f4d95b', + '#66ff66', + '#58c8f4', + '#5b76f4', + '#f45bed' +]; + +let unique_id = 0; + +let unsaved_changes = false; + +const LAYOUT_HORIZONTAL = 0; +const LAYOUT_VERTICAL = 1; +let cur_layout = LAYOUT_HORIZONTAL; + +// Contains [[header, input, label]] +let all_headers = []; +let headers_orig_min_width; + +// DOM elems +let untiered_images; +let tierlist_div; +let dragged_image; + +function reset_row(row) { + row.querySelectorAll('span.item').forEach((item) => { + for (let i = 0; i < item.children.length; ++i) { + let img = item.children[i]; + item.removeChild(img); + untiered_images.appendChild(img); + } + item.parentNode.removeChild(item); + }); +} + +// Removes all rows from the tierlist, alongside their content. +// Also empties the untiered images. +function hard_reset_list() { + tierlist_div.innerHTML = ''; + untiered_images.innerHTML = ''; +} + +// Places back all the tierlist content into the untiered pool. +function soft_reset_list() { + tierlist_div.querySelectorAll('.row').forEach(reset_row); + unsaved_changes = true; +} + +window.addEventListener('load', () => { + untiered_images = document.querySelector('.images'); + tierlist_div = document.querySelector('.tierlist'); + + for (let i = 0; i < DEFAULT_TIERS.length; ++i) { + add_row(i, DEFAULT_TIERS[i]); + } + recompute_header_colors(); + + headers_orig_min_width = all_headers[0][0].clientWidth; + + make_accept_drop(document.querySelector('.images')); + + bind_title_events(); + + document.getElementById('load-img-input').addEventListener('input', (evt) => { + // @Speed: maybe we can do some async stuff to optimize this + let images = document.querySelector('.images'); + for (let file of evt.target.files) { + let reader = new FileReader(); + reader.addEventListener('load', (load_evt) => { + let img = create_img_with_src(load_evt.target.result); + images.appendChild(img); + unsaved_changes = true; + }); + reader.readAsDataURL(file); + } + }); + + // Allow copy-pasting image from clipboard + document.onpaste = (evt) => { + let clip_data = evt.clipboardData || evt.originalEvent.clipboardData; + let items = clip_data.items; + let images = document.querySelector('.images'); + for (let item of items) { + if (item.kind === 'file') { + let blob = item.getAsFile(); + let reader = new FileReader(); + reader.onload = (load_evt) => { + let img = create_img_with_src(load_evt.target.result); + images.appendChild(img); + unsaved_changes = true; + }; + reader.readAsDataURL(blob); + } + } + }; + + document.getElementById('reset-list-input').addEventListener('click', () => { + if (confirm('Reset Tierlist? (this will place all images back in the pool)')) { + soft_reset_list(); + } + }); + + document.getElementById('export-input').addEventListener('click', () => { + let name = prompt('Please give a name to this tierlist'); + if (name) { + save_tierlist(`${name}.json`); + } + }); + + document.getElementById('import-input').addEventListener('input', (evt) => { + if (!evt.target.files) { + return; + } + let file = evt.target.files[0]; + let reader = new FileReader(); + reader.addEventListener('load', (load_evt) => { + let raw = load_evt.target.result; + let parsed = JSON.parse(raw); + if (!parsed) { + alert("Failed to parse data"); + return; + } + hard_reset_list(); + load_tierlist(parsed); + }); + reader.readAsText(file); + }); + + bind_trash_events(); + bind_toggle_layout_events(); + + window.addEventListener('beforeunload', (evt) => { + if (!unsaved_changes) return null; + var msg = "You have unsaved changes. Leave anyway?"; + (evt || window.event).returnValue = msg; + return msg; + }); + + void try_load_tierlist_json(); +}); + +function create_img_with_src(src) { + let img = document.createElement('img'); + img.src = src; + img.style.userSelect = 'none'; + img.classList.add('draggable'); + img.draggable = true; + img.ondragstart = "event.dataTransfer.setData('text/plain', null)"; + img.addEventListener('mousedown', (evt) => { + dragged_image = evt.target; + dragged_image.classList.add("dragged"); + }); + return img; +} + +function save(filename, text) { + unsaved_changes = false; + + var el = document.createElement('a'); + el.setAttribute('href', 'data:text/html;charset=utf-8,' + encodeURIComponent(text)); + el.setAttribute('download', filename); + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); +} + +function save_tierlist(filename) { + let serialized_tierlist = { + title: document.querySelector('.title-label').innerText, + rows: [], + }; + tierlist_div.querySelectorAll('.row').forEach((row, i) => { + serialized_tierlist.rows.push({ + name: row.querySelector('.header label').innerText.substr(0, MAX_NAME_LEN) + }); + serialized_tierlist.rows[i].imgs = []; + row.querySelectorAll('img').forEach((img) => { + serialized_tierlist.rows[i].imgs.push(img.src); + }); + }); + + let untiered_imgs = document.querySelectorAll('.images img'); + if (untiered_imgs.length > 0) { + serialized_tierlist.untiered = []; + untiered_imgs.forEach((img) => { + serialized_tierlist.untiered.push(img.src); + }); + } + + save(filename, JSON.stringify(serialized_tierlist)); +} + +function load_tierlist(serialized_tierlist) { + document.querySelector('.title-label').innerText = serialized_tierlist.title; + for (let idx in serialized_tierlist.rows) { + let ser_row = serialized_tierlist.rows[idx]; + let elem = add_row(idx, ser_row.name); + + for (let img_src of ser_row.imgs ?? []) { + let img = create_img_with_src(img_src); + let td = document.createElement('span'); + td.classList.add('item'); + td.appendChild(img); + let items_container = elem.querySelector('.items'); + items_container.appendChild(td); + } + + elem.querySelector('label').innerText = ser_row.name; + } + recompute_header_colors(); + + if (serialized_tierlist.untiered) { + let images = document.querySelector('.images'); + for (let img_src of serialized_tierlist.untiered) { + let img = create_img_with_src(img_src); + images.appendChild(img); + } + } + + resize_headers(); + + unsaved_changes = false; +} + +function end_drag(evt) { + dragged_image?.classList.remove("dragged"); + dragged_image = null; +} + +window.addEventListener('mouseup', end_drag); +window.addEventListener('dragend', end_drag); + +function make_accept_drop(elem) { + elem.classList.add('droppable'); + + elem.addEventListener('dragenter', (evt) => { + evt.target.classList.add('drag-entered'); + }); + elem.addEventListener('dragleave', (evt) => { + evt.target.classList.remove('drag-entered'); + }); + elem.addEventListener('dragover', (evt) => { + evt.preventDefault(); + }); + elem.addEventListener('drop', (evt) => { + evt.preventDefault(); + evt.target.classList.remove('drag-entered'); + + if (!dragged_image) { + return; + } + + let dragged_image_parent = dragged_image.parentNode; + if (dragged_image_parent.tagName.toUpperCase() === 'SPAN' && + dragged_image_parent.classList.contains('item')) { + // We were already in a tier + let containing_tr = dragged_image_parent.parentNode; + containing_tr.removeChild(dragged_image_parent); + } else { + dragged_image_parent.removeChild(dragged_image); + } + let td = document.createElement('span'); + td.classList.add('item'); + td.appendChild(dragged_image); + let items_container = elem.querySelector('.items'); + if (!items_container) { + // Quite lazy hack for
+ items_container = elem; + } + items_container.appendChild(td); + + unsaved_changes = true; + }); +} + +function enable_edit_on_click(container, input, label) { + function change_label(evt) { + input.style.display = 'none'; + label.innerText = input.value; + label.style.display = 'inline'; + unsaved_changes = true; + } + + input.addEventListener('change', change_label); + input.addEventListener('focusout', change_label); + + container.addEventListener('click', (evt) => { + label.style.display = 'none'; + input.value = label.innerText.substr(0, MAX_NAME_LEN); + input.style.display = 'inline'; + input.select(); + }); +} + +function bind_title_events() { + let title_label = document.querySelector('.title-label'); + let title_input = document.getElementById('title-input'); + let title = document.querySelector('.title'); + + enable_edit_on_click(title, title_input, title_label); +} + +function create_label_input(row, row_idx, row_name) { + let input = document.createElement('input'); + input.id = `input-tier-${unique_id++}`; + input.type = 'text'; + input.addEventListener('change', resize_headers); + let label = document.createElement('label'); + label.htmlFor = input.id; + label.innerText = row_name; + + let header = row.querySelector('.header'); + all_headers.splice(row_idx, 0, [header, input, label]); + header.appendChild(label); + header.appendChild(input); + + enable_edit_on_click(header, input, label); +} + +function resize_headers() { + let max_width = headers_orig_min_width; + for (let [other_header, _i, label] of all_headers) { + max_width = Math.max(max_width, label.clientWidth); + } + + for (let [other_header, _i2, _l2] of all_headers) { + other_header.style.minWidth = `${max_width}px`; + } +} + +function add_row(index, name) { + let div = document.createElement('div'); + let header = document.createElement('span'); + let items = document.createElement('span'); + div.classList.add('row'); + header.classList.add('header'); + items.classList.add('items'); + div.appendChild(header); + div.appendChild(items); + let row_buttons = document.createElement('div'); + row_buttons.classList.add('row-buttons'); + let btn_plus_up = document.createElement('input'); + btn_plus_up.type = "button"; + btn_plus_up.value = '+'; + btn_plus_up.title = "Add row above"; + btn_plus_up.addEventListener('click', (evt) => { + let parent_div = evt.target.parentNode.parentNode; + let rows = Array.from(tierlist_div.children); + let idx = rows.indexOf(parent_div); + console.assert(idx >= 0); + add_row(idx, ''); + recompute_header_colors(); + }); + let btn_rm = document.createElement('input'); + btn_rm.type = "button"; + btn_rm.value = '-'; + btn_rm.title = "Remove row"; + btn_rm.addEventListener('click', (evt) => { + let rows = Array.from(tierlist_div.querySelectorAll('.row')); + if (rows.length < 2) return; + let parent_div = evt.target.parentNode.parentNode; + let idx = rows.indexOf(parent_div); + console.assert(idx >= 0); + if (rows[idx].querySelectorAll('img').length === 0 || + confirm(`Remove tier ${rows[idx].querySelector('.header label').innerText}? (This will move back all its content to the untiered pool)`)) + { + rm_row(idx); + } + recompute_header_colors(); + }); + let btn_plus_down = document.createElement('input'); + btn_plus_down.type = "button"; + btn_plus_down.value = '+'; + btn_plus_down.title = "Add row below"; + btn_plus_down.addEventListener('click', (evt) => { + let parent_div = evt.target.parentNode.parentNode; + let rows = Array.from(tierlist_div.children); + let idx = rows.indexOf(parent_div); + console.assert(idx >= 0); + add_row(idx + 1, name); + recompute_header_colors(); + }); + row_buttons.appendChild(btn_plus_up); + row_buttons.appendChild(btn_rm); + row_buttons.appendChild(btn_plus_down); + div.appendChild(row_buttons); + + let rows = tierlist_div.children; + if (index === rows.length) { + tierlist_div.appendChild(div); + } else { + let nxt_child = rows[index]; + tierlist_div.insertBefore(div, nxt_child); + } + + make_accept_drop(div); + create_label_input(div, index, name); + + return div; +} + +function rm_row(idx) { + let row = tierlist_div.children[idx]; + reset_row(row); + tierlist_div.removeChild(row); +} + +function recompute_header_colors() { + tierlist_div.querySelectorAll('.row').forEach((row, row_idx) => { + let color = TIER_COLORS[row_idx % TIER_COLORS.length]; + row.querySelector('.header').style.backgroundColor = color; + }); +} + +function bind_trash_events() { + let trash = document.getElementById('trash'); + trash.classList.add('droppable'); + trash.addEventListener('dragenter', (evt) => { + evt.preventDefault(); + evt.target.src = 'trash_bin_open.png'; + }); + trash.addEventListener('dragexit', (evt) => { + evt.preventDefault(); + evt.target.src = 'trash_bin.png'; + }); + trash.addEventListener('dragover', (evt) => { + evt.preventDefault(); + }); + trash.addEventListener('drop', (evt) => { + evt.preventDefault(); + evt.target.src = 'trash_bin.png'; + if (dragged_image) { + let dragged_image_parent = dragged_image.parentNode; + if (dragged_image_parent.tagName.toUpperCase() === 'SPAN' && + dragged_image_parent.classList.contains('item')) + { + // We were already in a tier + let containing_tr = dragged_image_parent.parentNode; + containing_tr.removeChild(dragged_image_parent); + } + dragged_image.remove(); + } + }); +} + +function bind_toggle_layout_events() { + let toggle = document.getElementById('toggle-layout'); + toggle.addEventListener('click', () => { + set_layout((cur_layout + 1) % 2); + }); +} + +function set_layout(layout) { + let main = document.getElementsByClassName("main-content")[0]; + if (layout === LAYOUT_VERTICAL) { + main.classList.add("vertical"); + } else { + main.classList.remove("vertical"); + } + cur_layout = layout; +} + +function is_url (str) { + try { + new URL(str); + return true; + } catch (e) { + return false; + } +} + +// Fetches a tierlist JSON file from the 'url' query parameter and loads it +async function try_load_tierlist_json () { + const load_from_url = new URLSearchParams(window.location.search).get('url'); + if (load_from_url !== null && is_url(load_from_url)) { + try { + let result = await fetch(load_from_url); + result = await result.json(); + hard_reset_list(); + load_tierlist(result); + } catch (e) { console.error(e); } + } +} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..a0cfef2 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,245 @@ +body { + background-color: #333; + font-family: sans-serif; +} + +.title { + color: #ddd; + font-size: 38px; + user-select: none; + text-align: center; + margin-bottom: 20px; + min-height: 38px; + cursor: pointer; +} + +.title label { + cursor: pointer; +} + +.title input { + display: none; + background: transparent; + border: 0px; + color: #ddd; + font-size: 38px; +} + +.title input:focus { + background: #666; +} + +.main-content { + display: flex; + flex-flow: column; +} + +.main-content.vertical { + flex-flow: row !important; +} + +.vertical .tierlist { + width: 80%; +} + +.toggleable-container { + display: flex; + flex-flow: column; + flex-grow: 1; +} + +.tierlist span { + min-width: 100px; + min-height: 100px; + display: flex; + align-items: center; + flex-flow: row; + font-size: 30px; + font-family: sans-serif; + border: 0; + padding: 0; +} + +.tierlist div.row { + border: 0px solid #666; + border-top-width: 1px; + border-right-width: 1px; + display: flex; + user-select: none; +} + +.tierlist div:last-child { + border-bottom-width: 1px !important; +} + +.tierlist div.row:hover { + background-color: #666; +} + +.tierlist div.row.drag-entered { + background-color: #888; +} + +span.header { + justify-content: center; + align-self: stretch; + cursor: pointer; +} + +span.header label { + cursor: pointer; +} + +span.header input[type=text] { + display: none; + height: 50px; + font-size: 30px; + text-align: center; +} + +span.items { + display: flex; + flex-wrap: wrap; + flex-grow: 1; + justify-content: left; + pointer-events: none; + height: fit-content; +} + +span.item { + pointer-events: auto; +} + +.images { + display: flex; + border: 1px solid #666; + height: 200px; + overflow-y: auto; + flex-wrap: wrap; + flex-grow: 1; + width: 100%; + align-content: flex-start; +} + +.images.drag-entered { + background-color: #888; +} + +img.draggable { + cursor: grab; + max-height: 100px; + width: auto; + height: auto; + object-fit: contain; +} + +img.draggable:hover, .button:hover { + filter: brightness(0.6); +} + +img.draggable.dragged, .button:active { + filter: brightness(0.4); +} + +.button img { + user-select: none; +} + +.bottom-container { + display: flex; + flex-flow: row; + align-items: center; + margin-top: 15px; + flex-grow: 1; +} + +.vertical .bottom-container { + flex-flow: column !important; + margin-left: 15px; + margin-top: 0px !important; +} + +.bottom-container input { + border: 0; + padding: 0; + display: none; +} + +.bottom-container > div { + margin: 0; +} + +.button { + border: 1px solid #666; + height: 100px; +} + +.row-buttons { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: space-evenly; + opacity: 0.2; + transition: 200ms linear; +} + +.row-buttons:hover { + opacity: 1; + transition: 200ms linear; +} + +.buttons-container { + display: flex; + flex-wrap: wrap; + width: 270px; +} + +.vertical .buttons-container { + justify-content: center; +} + +.gh-link { + position: absolute; + top: 5px; + color: #ccc; + text-decoration: none; +} + +.top-container { + position: absolute; + top: 5px; + right: 5px; + height: 60px; + display: flex; + flex-flow: row; +} + +.top-container * { + height: 100%; + overflow: hidden; + margin: 0; + padding-left: 5px; + padding-right: 5px; + border: 0; +} + +.top-container input { + border: 0; + padding: 0; + display: none; +} + +#trash { + opacity: 0.4; +} + +#trash:hover { + opacity: 1; +} + +.hint { + font-style: italic; + font-size: small; + color: #ccc; + padding: 0px 10px; +} \ No newline at end of file -- cgit v1.2.3