621 lines
23 KiB
HTML
621 lines
23 KiB
HTML
<link rel="icon" href="https://s3.dualstack.us-east-2.amazonaws.com/pythondotorg-assets/media/files/python-logo-only.svg" type="image/svg+xml">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/grids-responsive-min.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/utc.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/timezone.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/duration.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/relativeTime.js"></script>
|
||
<title>Python Application</title>
|
||
|
||
<form class="pure-form pure-form-stacked pure-u-11-12 pure-u-lg-3-4 pure-u-xl-2-3">
|
||
<fieldset>
|
||
<legend>Basic Information</legend>
|
||
<div class="gaps pure-g">
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">File Name</span>
|
||
<span class="less ellipsis pure-u-3-4" id="fileLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Subject</span>
|
||
<span class="less ellipsis pure-u-3-4" id="subjectLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">From</span>
|
||
<span class="less ellipsis pure-u-3-4" id="fromLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Recipient</span>
|
||
<span class="less ellipsis pure-u-3-4" id="recipientLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Progress</span>
|
||
<span class="less ellipsis pure-u-3-4" id="progressLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Timezone</span>
|
||
<span class="less ellipsis pure-u-3-4" id="timezoneLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Uptime</span>
|
||
<span class="less ellipsis pure-u-3-4" id="uptimeLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Remaining</span>
|
||
<span class="less ellipsis pure-u-3-4" id="remainingLabel"></span>
|
||
</div>
|
||
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
|
||
<span class="ellipsis pure-u-1-4">Status</span>
|
||
<span class="less ellipsis pure-u-3-4" id="statusLabel"></span>
|
||
</div>
|
||
</div>
|
||
<br>
|
||
<button type="button" class="inline-flex pure-button pure-button-primary" id="send">
|
||
<span class="text">Open</span>
|
||
<span class="icon spin" hidden>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-loader">
|
||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||
<path d="M12 6l0 -3" />
|
||
<path d="M16.25 7.75l2.15 -2.15" />
|
||
<path d="M18 12l3 0" />
|
||
<path d="M16.25 16.25l2.15 2.15" />
|
||
<path d="M12 18l0 3" />
|
||
<path d="M7.75 16.25l-2.15 2.15" />
|
||
<path d="M6 12l-3 0" />
|
||
<path d="M7.75 7.75l-2.15 -2.15" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
<button type="button" class="pure-button" id="skip" disabled>
|
||
Skip
|
||
</button>
|
||
<button type="button" class="pure-button" id="cancel" disabled>
|
||
Cancel
|
||
</button>
|
||
</fieldset>
|
||
<br>
|
||
<fieldset>
|
||
<legend>Parameters</legend>
|
||
<div class="pure-g">
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="timeout">Timeout</label>
|
||
<input id="timeout" class="pure-u-23-24" type="number" min="0"/>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="interval">Interval</label>
|
||
<input id="interval" class="pure-u-23-24" type="number" min="0"/>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="max_occurrence">Max Occurrence</label>
|
||
<input id="max_occurrence" class="inactive pure-u-23-24" type="number" min="0" value="5" disabled/>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="attempts">Attempts</label>
|
||
<input id="attempts" class="pure-u-23-24" type="number" min="1"/>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="locale">Locale</label>
|
||
<select id="locale" class="pure-input-1-2" required>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<br>
|
||
<label for="greet" class="pure-checkbox">
|
||
<input id="greet" type="checkbox"/> Greet recipients (by proper names if possible)
|
||
</label>
|
||
<label for="recovery" class="pure-checkbox">
|
||
<input id="recovery" type="checkbox"/> Try recover from a mismatched address/subject error
|
||
</label>
|
||
<label for="slice" class="pure-checkbox">
|
||
<input id="slice" type="checkbox"/> Enable task slicing
|
||
</label>
|
||
<label for="nospam" class="pure-checkbox">
|
||
<input id="nospam" type="checkbox"/> Avoid spamming
|
||
</label>
|
||
<label for="save" class="pure-checkbox">
|
||
<input id="save" type="checkbox" checked/> Save to file
|
||
</label>
|
||
</fieldset>
|
||
<br>
|
||
<fieldset>
|
||
<legend>Columns</legend>
|
||
<div class="pure-g">
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_address">Recipient Address</label>
|
||
<select id="column_address" class="columns pure-u-23-24" required>
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_name">Recipient Name</label>
|
||
<select id="column_name" class="columns pure-u-23-24">
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_code">Reference Code</label>
|
||
<select id="column_code" class="columns pure-u-23-24">
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_pays">Country</label>
|
||
<select id="column_pays" class="columns pure-u-23-24">
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_sent">Remarks</label>
|
||
<select id="column_sent" class="columns pure-u-23-24">
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="column_vars">Variables</label>
|
||
<select id="column_vars" class="columns pure-u-23-24">
|
||
<option value=""> </option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
<br>
|
||
<fieldset>
|
||
<legend>Slicing</legend>
|
||
<div class="pure-g">
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="group_by">Group by</label>
|
||
<select id="group_by" class="slicing localonly pure-u-23-24" disabled>
|
||
<option value="none">None</option>
|
||
<option value="locale">Locale</option>
|
||
<option value="country">Country</option>
|
||
</select>
|
||
<label for="subcategory">Subcategory</label>
|
||
<select id="subcategory" class="slicing inactive localonly pure-u-23-24" multiple disabled>
|
||
</select>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<label for="chunk_size">Chunk Size</label>
|
||
<input id="chunk_size" class="slicing pure-u-23-24" type="number" min="100" value="1000" disabled/>
|
||
</div>
|
||
<div class="pure-u-1 pure-u-md-1-3">
|
||
<div class="inline-flex">
|
||
<div class="pure-u-1-2">
|
||
<label for="offset">Offset</label>
|
||
<input id="offset" class="slicing pure-u-23-24" type="number" min="0" disabled/>
|
||
</div>
|
||
<div class="pure-u-1-2">
|
||
<label for="limit">Max. (limit)</label>
|
||
<input id="limit" class="slicing localonly pure-u-23-24" type="number" readonly disabled/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
</form>
|
||
|
||
<script type="text/javascript">
|
||
dayjs.extend(dayjs_plugin_utc);
|
||
dayjs.extend(dayjs_plugin_timezone);
|
||
dayjs.extend(dayjs_plugin_duration);
|
||
dayjs.extend(dayjs_plugin_relativeTime);
|
||
|
||
const Timer = {
|
||
setTimestamp: (reset=false) => {
|
||
$('#uptimeLabel').dataset.timestamp = dayjs().unix();
|
||
if (reset) $('#uptimeLabel').dataset.timedelta = 0;
|
||
},
|
||
setTimedelta: () => {
|
||
$('#uptimeLabel').dataset.timedelta = Timer.getTimedelta().asSeconds();
|
||
},
|
||
getTimedelta: () => {
|
||
let timedelta = dayjs.duration(dayjs().diff(dayjs.unix($('#uptimeLabel').dataset.timestamp)));
|
||
let sum = dayjs.duration($('#uptimeLabel').dataset.timedelta||0, 'second').add(timedelta);
|
||
return sum;
|
||
},
|
||
};
|
||
|
||
const $ = (selectors) => {
|
||
return document.querySelector(selectors);
|
||
};
|
||
|
||
const $$ = (selectors) => {
|
||
return document.querySelectorAll(selectors);
|
||
};
|
||
|
||
const $$$ = (selectors, event, listener) => {
|
||
$(selectors).addEventListener(event, listener);
|
||
};
|
||
|
||
let Limit = null;
|
||
let Status = null;
|
||
let Columns = null;
|
||
let Locales = null;
|
||
let Connection = null;
|
||
|
||
$$$('#send', 'click', async (e) => {
|
||
switch (Status) {
|
||
case 'STANDBY':
|
||
Connection.send('RESUME');
|
||
$('#send > span.text').innerText = 'Pause';
|
||
$('#send > span.icon').removeAttribute('hidden');
|
||
$('#send').disabled = true;
|
||
break;
|
||
case 'RUNNING':
|
||
Connection.send('ONHOLD');
|
||
$('#send > span.text').innerText = 'Resume';
|
||
$('#send > span.icon').setAttribute('hidden', '');
|
||
$('#send').disabled = true;
|
||
break;
|
||
case 'READY':
|
||
let parameters = {};
|
||
|
||
for (let element of $$("input[type='number']:not([disabled]):not(.localonly)")) {
|
||
parameters[element.id] = element.valueAsNumber;
|
||
}
|
||
|
||
for (let element of $$("select:not([disabled]):not(.localonly)")) {
|
||
parameters[element.id] = element.value || element.dataset.default;
|
||
}
|
||
|
||
for (let element of $$("input[type='checkbox']:not([disabled])")) {
|
||
parameters[element.id] = element.checked;
|
||
}
|
||
|
||
if (parameters.slice && $('#subcategory').dataset.values) {
|
||
parameters.subcategory = $('#subcategory').dataset.values.split(',');
|
||
}
|
||
|
||
for (let element of $$("input:not(.inactive), select:not(.inactive)")) {
|
||
element.disabled = true;
|
||
}
|
||
|
||
Connection.send(JSON.stringify(parameters));
|
||
Status = 'RUNNING';
|
||
Timer.setTimestamp(true);
|
||
|
||
$('#send > span.text').innerText = 'Pause';
|
||
$('#send > span.icon').removeAttribute('hidden');
|
||
$('#skip').disabled = false;
|
||
break;
|
||
default:
|
||
try {
|
||
let PickerOptions = {
|
||
types: [
|
||
{
|
||
description: "Excel Spreadsheet",
|
||
accept: {
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ['.xlsx']
|
||
}
|
||
}
|
||
]
|
||
};
|
||
$('#send > span.icon').removeAttribute('hidden');
|
||
let [handle] = await showOpenFilePicker(PickerOptions);
|
||
let file = await handle.getFile();
|
||
|
||
let buffer = await new Promise((resolve, reject) => {
|
||
let reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
|
||
Connection.send(file.name);
|
||
Connection.send(buffer);
|
||
Status = 'READY';
|
||
|
||
$('#fileLabel').innerText = file.name;
|
||
$('#send > span.text').innerText = 'Send';
|
||
$('#cancel').disabled = false;
|
||
$('#send').disabled = true;
|
||
} finally {
|
||
$('#send > span.icon').setAttribute('hidden', '');
|
||
}
|
||
}
|
||
});
|
||
|
||
$$$('#skip', 'click', (e) => {
|
||
Connection.send('BYPASS');
|
||
if (Status === 'STANDBY') Connection.send('ONHOLD');
|
||
$('#skip').disabled = true;
|
||
});
|
||
|
||
$$$('#cancel', 'click', (e) => {
|
||
Connection.send('CANCEL');
|
||
$('#cancel').disabled = true;
|
||
});
|
||
|
||
$$$('#locale', 'change', (e) => {
|
||
$('#group_by').dispatchEvent(new Event('change'));
|
||
$('#timezoneLabel').dataset.timezone = Locales.find((x) => x.locale == e.target.value).timezone;
|
||
});
|
||
|
||
$$$('#slice', 'change', (e) => {
|
||
for (let element of $$('.slicing:not(.inactive)')) {
|
||
element.disabled = !e.target.checked;
|
||
}
|
||
$('#subcategory').dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
$$$('#nospam', 'change', (e) => {
|
||
$('#max_occurrence').classList.toggle('inactive');
|
||
$('#max_occurrence').disabled = !e.target.checked;
|
||
$('#column_code').required = e.target.checked;
|
||
});
|
||
|
||
$$$('#column_pays', 'change', (e) => {
|
||
$('#group_by').dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
$$$('#group_by', 'change', (e) => {
|
||
let columns = Columns ? Columns[$('#column_pays').value] : null;
|
||
let countries = Object.assign({}, columns);
|
||
let results = {};
|
||
|
||
switch (e.target.value) {
|
||
case 'none':
|
||
case 'country':
|
||
results = countries;
|
||
break;
|
||
case 'locale':
|
||
let locale = $('#locale').value;
|
||
let targets = Locales.toReversed();
|
||
|
||
for (let target of targets) {
|
||
for (let key of Object.keys(countries)) {
|
||
if (target.predicate.length == 0 || target.predicate.includes(key)) {
|
||
if (target.locale === locale) {
|
||
results[key] = countries[key];
|
||
}
|
||
delete countries[key];
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
switch (e.target.value) {
|
||
case 'none':
|
||
$('#subcategory').classList.add('inactive');
|
||
$('#subcategory').disabled = true;
|
||
break;
|
||
case 'country':
|
||
case 'locale':
|
||
$('#subcategory').classList.remove('inactive');
|
||
$('#subcategory').disabled = false;
|
||
break;
|
||
}
|
||
$('#subcategory').options.length = 0;
|
||
|
||
for (let [key, value] of Object.entries(results)) {
|
||
let option = new Option(`${key} (${value})`, key);
|
||
option.dataset.count = value;
|
||
$('#subcategory').options.add(option);
|
||
}
|
||
$('#subcategory').dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
$$$('#subcategory', 'change', (e) => {
|
||
let options = Array.from(e.target.options).filter((v) => v.selected);
|
||
let values = options.map((o) => o.value).join(',');
|
||
let limit = options.reduce((i, v) => i + Number(v.dataset.count), 0);
|
||
|
||
$('#subcategory').dataset.limit = limit;
|
||
$('#subcategory').dataset.values = values;
|
||
$('#chunk_size').dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
$$$('#chunk_size', 'change', (e) => {
|
||
let limit = Number($('#subcategory').dataset.limit) || Limit;
|
||
let size = Math.floor(limit / e.target.value);
|
||
|
||
$('#limit').value = size;
|
||
$('#offset').max = size;
|
||
$('#offset').value = 0;
|
||
$('#offset').dispatchEvent(new Event('change'));
|
||
});
|
||
|
||
$$$('#offset', 'change', (e) => {
|
||
let limit = Number($('#subcategory').dataset.limit) || Limit;
|
||
let size = Number($('#chunk_size').value) || 0;
|
||
|
||
if ($('#slice').checked) {
|
||
limit = Math.min(size, limit - size * Number(e.target.value));
|
||
}
|
||
e.target.dataset.limit = limit;
|
||
});
|
||
|
||
function main(url, parameters, locales) {
|
||
Connection = new WebSocket(url);
|
||
Locales = JSON.parse(locales);
|
||
|
||
for (let [key, value] of Object.entries(parameters)) {
|
||
$(`input#${key}`)?.setAttribute('value', value);
|
||
}
|
||
|
||
for (let item of Locales) {
|
||
$('#locale').add(new Option(item.locale.toUpperCase(), item.locale));
|
||
$('#locale').dispatchEvent(new Event('change'))
|
||
}
|
||
|
||
Connection.addEventListener('message', (e) => {
|
||
let command = JSON.parse(e.data);
|
||
let {name, args} = command;
|
||
|
||
switch (name) {
|
||
case 'setAddress':
|
||
let [person, address] = args;
|
||
document.title = `${person} (${address})`;
|
||
$('#fromLabel').innerText = address;
|
||
break;
|
||
case 'setSubject':
|
||
let [subject] = args;
|
||
$('#subjectLabel').innerText = subject;
|
||
break;
|
||
case 'setMetadata':
|
||
[Limit, Columns] = args;
|
||
|
||
for (let element of $$('.columns')) {
|
||
let options = element.options;
|
||
options.length = 1;
|
||
|
||
for (let column of Object.keys(Columns)) {
|
||
options.add(new Option(column, column, false, column === parameters[element.id]));
|
||
}
|
||
element.dataset.default = parameters[element.id];
|
||
element.dispatchEvent(new Event('change'));
|
||
}
|
||
|
||
$('#send').disabled = false;
|
||
$('#cancel').disabled = false;
|
||
break;
|
||
case 'setProgress':
|
||
let [index, name, recipient, sent, warnings, errors, attempt] = args;
|
||
let label = name ? `${name} <${recipient}>` : recipient;
|
||
$('#recipientLabel').innerText = label;
|
||
$('#progressLabel').dataset.sent = sent;
|
||
$('#progressLabel').dataset.index = index;
|
||
$('#progressLabel').dataset.errors = errors;
|
||
$('#progressLabel').dataset.attempt = attempt;
|
||
$('#progressLabel').dataset.warnings = warnings;
|
||
break;
|
||
case 'setStatus':
|
||
switch (args[0]) {
|
||
case 'RESUME':
|
||
Timer.setTimestamp();
|
||
case 'BYPASS':
|
||
Status = 'RUNNING';
|
||
$('#send').disabled = false;
|
||
$('#skip').disabled = false;
|
||
break;
|
||
case 'ONHOLD':
|
||
Status = 'STANDBY';
|
||
$('#send').disabled = false;
|
||
Timer.setTimedelta();
|
||
break;
|
||
case 'FAILED':
|
||
Status = 'STANDBY';
|
||
$('#send > span.text').innerText = 'Resume';
|
||
$('#send > span.icon').setAttribute('hidden', '');
|
||
Timer.setTimedelta();
|
||
break;
|
||
case 'FINISH':
|
||
let {sent, warnings, errors} = $('#progressLabel').dataset;
|
||
alert(`${sent||0} Sent; ${warnings||0} Warning(s); ${errors||0} Error(s)`);
|
||
case 'CANCEL':
|
||
Status = null;
|
||
$('#send > span.text').innerText = 'Open';
|
||
$('#send > span.icon').setAttribute('hidden', '');
|
||
$('#cancel').disabled = true;
|
||
$('#skip').disabled = true;
|
||
|
||
for (let element of $$("input:not(.inactive):not(.slicing)")) {
|
||
element.disabled = false;
|
||
}
|
||
|
||
for (let element of $$("select:not(.inactive):not(.slicing)")) {
|
||
element.disabled = false;
|
||
}
|
||
|
||
for (let element of $$("input[type='checkbox']")) {
|
||
element.dispatchEvent(new Event('change'));
|
||
}
|
||
break;
|
||
}
|
||
break;
|
||
case 'tell':
|
||
let [message, level] = args;
|
||
let prefix = level === 0 ? 'ERROR' : level === 1 ? 'WARNING' : 'INFO';
|
||
console.log(`[${prefix}] ${message}`);
|
||
if (level === 0) alert(message);
|
||
break;
|
||
}
|
||
});
|
||
|
||
let clock = setInterval(() => {
|
||
let timezone = $('#timezoneLabel').dataset.timezone;
|
||
let city = timezone.split('/')[1];
|
||
let date = dayjs().tz(timezone).format("YYYY-MM-DD HH:mm:ss");
|
||
|
||
$('#timezoneLabel').innerText = `${date} (${city})`;
|
||
$('#statusLabel').innerText = Status ? Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase() : 'Idle';
|
||
|
||
if (Status === 'RUNNING') {
|
||
let index = Number($('#progressLabel').dataset.index) || 0;
|
||
let limit = Number($('#offset').dataset.limit) || 0;
|
||
|
||
let percentage = parseFloat((index / limit * 100).toFixed(2));
|
||
$('#progressLabel').innerText = `${index} / ${limit} (${percentage} %)`;
|
||
let uptime = Timer.getTimedelta();
|
||
$('#uptimeLabel').innerText = uptime.format("HH:mm:ss");
|
||
|
||
let rate = Number($('#progressLabel').dataset.sent) / uptime.asSeconds();
|
||
let spm = parseFloat(Number(rate*60).toFixed(2));
|
||
$('#remainingLabel').innerHTML = rate > 0 ? `<span>${dayjs.duration((limit - index) / rate, 'second').humanize()}</span>` : '';
|
||
$('#remainingLabel').innerHTML += spm >= 1 ? `<span class="${spm > 8.33 ? 'rate warning' : 'rate'}">${spm} per minute</span>` : '';
|
||
}
|
||
}, 500);
|
||
}
|
||
</script>
|
||
|
||
<style type="text/css">
|
||
body {
|
||
width: 100%;
|
||
margin: 2em 0 2em 0;
|
||
display: inline-flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
form {
|
||
padding: 0 1em 0 1em;
|
||
}
|
||
|
||
label {
|
||
user-select: none;
|
||
width: fit-content;
|
||
}
|
||
|
||
.rate:not(:first-child)::before {
|
||
padding: 0em 0.4em;
|
||
content: '–';
|
||
color: #666;
|
||
}
|
||
|
||
.inline-flex {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.ellipsis {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.warning {
|
||
color: orangered;
|
||
}
|
||
|
||
.gaps {
|
||
row-gap: 0.4em;
|
||
}
|
||
|
||
.icon {
|
||
height: 1em;
|
||
}
|
||
|
||
.less {
|
||
color: #666;
|
||
max-width: 72%;
|
||
}
|
||
|
||
.spin {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style> |