351 lines
11 KiB
HTML
351 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>
|
|
</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/piracy/direct_link/?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 playlistLink() {
|
|
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);
|
|
const playlistArg = encodeURIComponent(urls.join("\n"));
|
|
return `alfred://runtrigger/dev.abdus.labs/mpv_playlist/?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>
|