Files
playground/snot/snot.html
T

369 lines
11 KiB
HTML

<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: system-ui, sans-serif;
padding: 1rem;
font-size: 16px;
}
table,
th,
td {
border: 1px solid rgba(150, 150, 150, 0.5);
border-collapse: collapse;
}
tr:nth-child(even) {
background-color: rgba(150, 150, 150, 0.1);
}
th,
td {
padding: 0.5rem;
white-space: nowrap;
}
th {
text-align: left;
}
.input,
.button {
display: inline-block;
appearance: none;
cursor: pointer;
font-size: 16px;
padding: 0.75rem;
background: none;
text-decoration: none;
font-size: inherit;
color: inherit;
border: none;
border-radius: 0.5rem;
background-color: rgba(180, 180, 180, 0.2);
}
.button {
padding: 0.75rem 1rem;
text-transform: uppercase;
font-weight: bold;
font-size: 0.8em;
letter-spacing: 0.05em;
}
.button:hover,
.input:focus {
background-color: rgba(200, 200, 200, 0.2);
}
.button:active {
background-color: rgba(120, 120, 120, 0.2);
}
.error {
background-color: rgb(170, 42, 42);
color: white;
padding: 0.5rem 0;
display: block;
}
.results {
border: none;
padding-right: 1rem;
display: inline-block;
}
.gapped > * + * {
margin-top: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
justify-content: center;
}
.row {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.filter-bar {
position: fixed;
padding: 0.5rem;
bottom: 1rem;
right: 1rem;
z-index: 10;
opacity: 0;
transform: translateY(1rem);
transition: all 0.3s ease;
}
.filter-bar:focus-within,
.filter-bar:hover {
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transform: translateY(0);
opacity: 1;
}
.filter-bar > * {
display: block;
}
.gapped.small > * + * {
margin-top: 1rem;
}
.logo {
font-family: "JetBrains Mono", consolas, monospace;
font-weight: 900;
text-transform: uppercase;
font-size: 2rem;
margin: 2rem 0;
letter-spacing: 0.25em;
text-align: center;
color: rgba(100, 100, 100, 0.5);
user-select: none;
pointer-events: none;
}
.actor-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
max-width: 380px;
}
.actor-pill {
border: 1px solid rgba(120, 120, 120, 0.35);
border-radius: 999px;
background: rgba(120, 120, 120, 0.1);
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0.15rem 0.5rem;
white-space: nowrap;
}
.actor-pill:hover {
background: rgba(120, 120, 120, 0.2);
}
</style>
<h1 class="logo">s·n·o·t</h1>
<div x-data="app()" class="gapped" @keydown.window="onKeydown">
<form @submit.prevent="search" class="search-form">
<div class="row">
<input class="input" type="text" x-ref="query" x-model="form.query" placeholder="Search videos" style="width: 300px" />
<input class="button" type="submit" />
</div>
</form>
<div x-show="errorMessage" class="error">
<pre x-text="errorMessage"></pre>
</div>
<div class="results-wrapper">
<table class="results" x-show="visibleResults.length">
<tr>
<template x-for="col in columns">
<th
x-text="col.label + (col.sortable && sort.by === col.key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : '')"
:style="col.sortable ? 'cursor:pointer;user-select:none' : ''"
@click="col.sortable && toggleSort(col.key)"
></th>
</template>
</tr>
<template x-for="item in visibleResults">
<tr>
<template x-for="col in columns">
<td>
<template x-if="col.key === 'file_name'">
<a :href="buildAlfredURL(item.download_url)" x-text="item.file_name"></a>
</template>
<template x-if="col.key === 'actors'">
<div class="actor-list">
<template x-for="actor in (item.actors ?? [])" :key="actor">
<button type="button" class="actor-pill" @click="addActorFilter(actor, $event)" x-text="actor"></button>
</template>
</div>
</template>
<template x-if="!['actors', 'file_name'].includes(col.key)">
<span x-text="col.value(item)"></span>
</template>
</td>
</template>
</tr>
</template>
</table>
</div>
<div x-show="playlistLink">
<a class="button" :href="playlistLink">Play all with mpv</a>
<a class="button" :href="gridLink">Play all in a grid</a>
</div>
<div class="filter-bar gapped small">
<input class="input" x-ref="filterInput" type="text" placeholder="Filter results" x-model="filter.query" />
<details>
<summary>Advanced</summary>
<div class="row">
<input id="minSizeMB" class="input" type="text" pattern="^\d*$" placeholder="Min size (MB)" x-model.number="filter.minSizeMB" />
</div>
</details>
</div>
</div>
<script defer src="./alpine@3.x.x.cdn.min.js"></script>
<script>
const app = () => ({
results: [],
errorMessage: "",
form: {
query: "",
},
filter: {
query: "",
minSizeMB: 0,
},
sort: { by: null, dir: "asc" },
buildAlfredURL(url) {
return `alfred://runtrigger/dev.abdus.alfred.media/video/?argument=${encodeURIComponent(url)}`;
},
toggleSort(field) {
if (this.sort.by === field) {
this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc";
} else {
this.sort.by = field;
this.sort.dir = "asc";
}
},
async onKeydown(e) {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return;
}
if (e.code == "Slash") {
e.preventDefault();
this.$refs.filterInput.focus();
}
},
get visibleResults() {
const isEmpty = !this.filter.query && this.filter.minSizeMB === 0;
let rows = isEmpty
? this.results
: this.results.filter((item) => {
const matchesQuery = item.file_name.toLowerCase().includes(this.filter.query.toLowerCase());
const matchesMinSize = item.size_mb >= +this.filter.minSizeMB;
return matchesQuery && matchesMinSize;
});
if (this.sort.by) {
rows = [...rows].sort((a, b) => {
const va = a[this.sort.by] ?? "";
const vb = b[this.sort.by] ?? "";
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true, sensitivity: "base" });
return this.sort.dir === "asc" ? cmp : -cmp;
});
}
return rows;
},
columns: [
{ key: "file_name", value: (it) => it.file_name, label: "File Name", sortable: true },
{ key: "actors", value: () => "", label: "Actors" },
{ key: "duration_human", value: (it) => it.duration_human, label: "Duration" },
{ key: "size_mb", value: (it) => it.size_mb, label: "Size (MB)" },
{ key: "resolution", value: (it) => it.resolution, label: "Resolution" },
{ key: "file_path", value: (it) => it.file_path, label: "File Path" },
{ key: "created_at", value: (it) => (it.created_at ? it.created_at.slice(0, 10) : ""), label: "Created At", sortable: true },
],
actorToken(actor) {
const escaped = actor.replaceAll('"', '\\"');
if (escaped.includes(" ")) {
return `actor:"${escaped}"`;
}
return `actor:${escaped}`;
},
hasActorFilter(actor) {
const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll('"', '\\"');
const pattern = new RegExp(`(^|\\s|\\|)actor:(?:"${escaped}"|${escaped})(?=\\s|\\||$)`, "i");
return pattern.test(this.form.query);
},
addActorFilter(actor, event) {
if (!actor) {
return;
}
const token = this.actorToken(actor);
if (event?.altKey) {
this.form.query = token;
this.search();
return;
}
if (this.hasActorFilter(actor)) {
return;
}
this.form.query = this.form.query ? `${this.form.query} ${token}` : token;
this.search();
},
async init() {
if (window.ENV?.query) {
this.form.query = window.ENV?.query ?? "";
await this.search();
}
},
get playableURLs() {
const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files"));
if (!playable) {
return null;
}
let urls = this.visibleResults
.filter((item) => item.file_path.startsWith("/mnt/box/files"))
.map((item) => item.download_url)
.sort(() => Math.random() - 0.5);
return urls;
},
get playlistLink() {
const urls = this.playableURLs;
if (!urls) {
return null;
}
const playlistArg = encodeURIComponent(urls.join("\n"));
return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`;
},
get gridLink() {
const urls = this.playableURLs;
if (!urls) {
return null;
}
const playlistArg = encodeURIComponent(urls.join("\n"));
return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`;
},
async search() {
const response = await fetch("/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
if (!response.ok) {
const errorResponse = await response.text();
this.errorMessage = "Error: " + errorResponse;
return;
}
this.results = await response.json();
window.app.setTitle(`s·m·u·t - ${this.results.length} results for "${this.form.query}"`);
},
});
</script>