Files
Hubert Chen 0658d744c1 fix preview cursor grabbing
convert:
    hide converted image default
preview:
    adjust background opacity
2026-04-26 16:50:01 +08:00

676 lines
20 KiB
JavaScript

class MetaParser {
static parse() {
const result = {
artwork: null,
socials: [],
files: [],
posts: [],
tags: []
};
const metas = document.querySelectorAll("meta[name]");
metas.forEach(meta => {
const name = meta.getAttribute("name");
const data = this.metaToObject(meta);
if (name === "artwork") {
result.artwork = data;
} else if (name.startsWith("social-")) {
result.socials.push(data);
} else if (name.startsWith("file-")) {
result.files.push(data);
} else if (name.startsWith("post-")) {
result.posts.push(data);
} else if (name.startsWith("tag-")) {
result.tags.push(data);
}
});
return result;
}
static metaToObject(meta) {
const obj = {};
for (const attr of meta.attributes) {
let value = attr.value;
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
} else if (Number.isSafeInteger(value) && !isNaN(value) && value.trim() !== "") {
value = Number(value);
}
obj[attr.name] = value;
}
return obj;
}
}
class Viewer {
constructor(wrapper, content) {
this.wrapper = wrapper;
this.content = content;
this.wrapper.addEventListener('wheel', this.wheel, { passive: false })
this.wrapper.addEventListener('pointerdown', this.down)
this.wrapper.addEventListener('pointermove', this.move)
this.wrapper.addEventListener('pointerup', this.up)
this.wrapper.addEventListener('pointercancel', this.up)
this.state = { x: 0, y: 0, scale: 1 }
this.input = { pointers: new Map(), wheel: null, dirty: false }
this.scale = { min: 0.5, max: 8 }
this.prevPan = null
this.prevPinch = null
document.body.style.touchAction = "none";
this.loop();
}
down = (e) => {
this.wrapper.style.cursor = "grabbing"
this.content.style.willChange = "transform"
this.wrapper.setPointerCapture(e.pointerId)
this.input.pointers.set(e.pointerId, {
x: e.clientX,
y: e.clientY,
})
this.input.dirty = true
}
move = (e) => {
const p = this.input.pointers.get(e.pointerId)
if (!p) return
p.x = e.clientX
p.y = e.clientY
this.input.dirty = true
}
up = (e) => {
this.wrapper.style.cursor = ""
this.content.style.willChange = ""
this.input.pointers.delete(e.pointerId)
this.input.dirty = true
}
wheel = (e) => {
e.preventDefault()
this.input.wheel = {
x: e.clientX,
y: e.clientY,
delta: e.deltaY,
}
this.input.dirty = true
}
loop = () => {
this.update()
requestAnimationFrame(this.loop);
}
update() {
if (!this.input.dirty) return
this.input.dirty = false
const pts = [...this.input.pointers.values()]
if (this.input.wheel) {
const { x, y, delta } = this.input.wheel
const scaleFactor = Math.exp(-delta * 0.001)
this.zoomAt(x, y, scaleFactor)
this.input.wheel = null
}
if (pts.length === 1) {
this.pan(pts[0])
} else if (pts.length >= 2) {
this.pinch(pts[0], pts[1])
}
if (pts.length < 2) this.prevPinch = null
if (pts.length !== 1) this.prevPan = null
this.render()
}
pan(p) {
if (!this.prevPan) {
this.prevPan = { ...p }
return
}
this.state.x += p.x - this.prevPan.x
this.state.y += p.y - this.prevPan.y
this.prevPan = { ...p }
}
pinch(p1, p2) {
const center = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
}
const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y)
if (!this.prevPinch) {
this.prevPinch = { center, dist }
return
}
const scaleFactor = dist / this.prevPinch.dist
this.zoomAt(center.x, center.y, scaleFactor)
this.state.x += center.x - this.prevPinch.center.x
this.state.y += center.y - this.prevPinch.center.y
this.prevPinch = { center, dist }
}
zoomAt(cx, cy, scaleFactor) {
const rect = this.content.getBoundingClientRect()
const dx = cx - (rect.left + rect.width / 2)
const dy = cy - (rect.top + rect.height / 2)
const prevScale = this.state.scale
const newScale = Math.max(this.scale.min, Math.min(this.scale.max, prevScale * scaleFactor))
const ratio = newScale / prevScale
this.state.x -= dx * (ratio - 1)
this.state.y -= dy * (ratio - 1)
this.state.scale = newScale
}
render() {
const { x, y, scale } = this.state
this.content.style.transform =
`translate(${x}px, ${y}px) scale(${scale})`
}
reset() {
this.state.x = 0
this.state.y = 0
this.state.scale = 1
this.render();
}
}
document.addEventListener('DOMContentLoaded', () => {
const pathIndex = parseInt(window.location.pathname.split("/photo/").pop());
let data = MetaParser.parse(),
index = pathIndex ? pathIndex : 0;
let preview = document.getElementById("preview");
preview.style.display = "flex";
let imageWrapper = document.getElementById("image-wrapper");
let previewImg = document.getElementById("preview-img");
let viewer = new Viewer(imageWrapper, previewImg);
let prevBtn = document.getElementById("prev-btn");
prevBtn.addEventListener("click", showPrevImage.bind(this));
let nextBtn = document.getElementById("next-btn");
nextBtn.addEventListener("click", showNextImage.bind(this));
document.addEventListener("keydown", handleKeyDown.bind(this));
let navControls = document.getElementById("nav-controls");
let downloadBtn = document.getElementById("download-btn");
downloadBtn.addEventListener("click", downloadImage.bind(this));
let deleteBtn = document.getElementById("delete-btn");
deleteBtn.addEventListener("click", deleteImage.bind(this));
let sidebar = document.getElementById("sidebar");
let sidebarBtn = document.getElementById("sidebar-btn");
sidebarBtn.addEventListener("click", toggleSidebar.bind(this));
let sidebarImageAuthor = document.getElementById("img-author");
let sidebarImageSource = document.getElementById("img-source");
let sidebarImageTag = document.getElementById("img-tag");
let sidebarImageInfo = document.getElementById("img-info");
let sidebarImageSetting = document.getElementById("img-setting");
let sidebarImageSettingSselect = document.getElementById("sensitive-select");
let sidebarImageSettingPselect = document.getElementById("private-select");
let sidebarImageSettingButton = document.getElementById("setting-button");
sidebarImageSettingButton.addEventListener("click", patchArtwork.bind(this));
let sidebarImageSettingPostInput = document.getElementById("post-input");
let sidebarImageSettingPostButton = document.getElementById("post-button");
sidebarImageSettingPostButton.addEventListener("click", postSource.bind(this));
let sidebarImageSettingUploadFileButton = document.getElementById("upload-file-button");
let sidebarImageSettingUploadURLInput = document.getElementById("upload-url-input");
let sidebarImageSettingUploadButton = document.getElementById("upload-button");
sidebarImageSettingUploadButton.addEventListener("click", postArtworkFile.bind(this));
let SettingSubmitting = false;
let on = false;
let status = localStorage.getItem("sidebar_on")
if (status) {
if (status === "true" && window.innerWidth > 640) {
on = true;
}
} else if (window.innerWidth > 1600) {
on = true;
}
if (on) openSidebar();
if (document.cookie.includes("_trpic_user")) {
deleteBtn.style.display = "flex";
sidebarImageSetting.style.display = "block";
}
prevBtn.style.opacity = 0;
nextBtn.style.opacity = 0;
showImage();
function postArtworkFile() {
const link = sidebarImageSettingUploadURLInput.value;
const files = sidebarImageSettingUploadFileButton.files;
if (link == "" && files.length == 0) {
alert("请填入链接或文件");
return;
}
SettingSubmitting = true;
sidebarImageSettingUploadButton.style.backgroundColor = "rgb(50, 50, 50)";
if (link) {
const formData = new FormData();
formData.append("link", link);
fetch(`/api/artworks/${data.artwork.id}/files`, {
method: "POST",
body: formData,
})
.then(res => {
res.json().then(resjson => {
switch (res.status) {
case 201:
data.files = resjson.edges.files;
showImage();
break;
case 401:
alert("提交失败:需要验证");
break;
default:
alert(`提交失败:${resjson.msg}: ${resjson.error}`);
break;
}
})
})
.finally(() => {
sidebarImageSettingUploadButton.style.backgroundColor = "rgb(50, 50, 50)";
sidebarImageSettingUploadURLInput.value = "";
sidebarImageSettingUploadFileButton.value = "";
SettingSubmitting = false;
})
.catch(error => {
alert(`请求失败: ${error}`);
});
} else {
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
fetch(`/api/artworks/${data.artwork.id}/files`, {
method: "POST",
body: formData,
})
.then(res => {
res.json().then(resjson => {
switch (res.status) {
case 201:
data.files = resjson.edges.files;
showImage();
break;
case 401:
alert("提交失败:需要验证");
break;
default:
alert(`提交失败:${resjson.msg}: ${resjson.error}`);
break;
}
})
})
.finally(() => {
sidebarImageSettingUploadButton.style.backgroundColor = "rgb(50, 50, 50)";
sidebarImageSettingUploadURLInput.value = "";
sidebarImageSettingUploadFileButton.value = "";
SettingSubmitting = false;
})
.catch(error => {
alert(`请求失败: ${error}`);
});
}
}
}
function postSource() {
const link = sidebarImageSettingPostInput.value;
if (link == "" || SettingSubmitting == true) return;
SettingSubmitting = true;
sidebarImageSettingPostButton.style.backgroundColor = "rgb(50, 80, 80)";
const formData = new FormData();
formData.append("link", link);
fetch(`/api/artworks/${data.artwork.id}/posts`, {
method: "POST",
body: formData,
})
.then(res => {
res.json().then(resjson => {
switch (res.status) {
case 200:
data.posts = resjson.edges.posts;
data.socials = resjson.edges.socials;
data.tags = resjson.edges.tags;
updateInfoBar();
break;
case 401:
alert("提交失败:需要验证");
break;
case 409:
if (confirm("此来源已链接到另一个作品,前往查看?")) {
window.location.href = `/artworks/${resjson.artwork_id}`;
}
break;
default:
alert(`提交失败:${resjson.msg}: ${resjson.error}`);
break;
}
})
})
.finally(() => {
sidebarImageSettingPostButton.style.backgroundColor = "rgb(50, 50, 50)";
sidebarImageSettingPostInput.value = "";
SettingSubmitting = false;
})
.catch(error => {
alert(`请求失败: ${error}`);
});
}
function patchArtwork() {
if (SettingSubmitting) return;
SettingSubmitting = true;
let Svalue = sidebarImageSettingSselect.value;
if (Svalue < 0 || Svalue > 5) {
SettingSubmitting = false;
return;
}
let Pvalue = sidebarImageSettingPselect.value;
const formData = new FormData();
formData.append("private", Pvalue);
formData.append("sensitive", Svalue);
sidebarImageSettingButton.style.backgroundColor = "rgb(50, 80, 80)";
fetch(`/api/artworks/${data.artwork.id}`, {
method: "PATCH",
body: formData,
})
.then(res => {
res.json().then(resjson => {
switch (res.status) {
case 200:
data.artwork.sensitive = resjson.sensitive ? resjson.sensitive : 0;
data.artwork.private = resjson.private ? true : false;
updateInfoBar();
break;
case 401:
alert("提交失败:需要验证");
break;
default:
alert(`提交失败:${resjson.msg}: ${resjson.error}`);
break;
}
})
})
.finally(() => {
SettingSubmitting = false;
setInterval(() => {
sidebarImageSettingButton.style.backgroundColor = "rgb(50, 50, 50)";
}, 1000)
})
.catch(error => {
alert(`请求失败: ${error}`);
});
}
function showImage() {
previewImg.style.opacity = 1;
viewer.reset();
let image = data.files[index];
previewImg.src = `/files/${image.id_str}.${image.ext}`;
updateNavButtons();
updateInfoBar();
previewImg.onerror = () => {
alert("Failed to load image");
};
}
function showPrevImage() {
if (index > 0) {
index = index - 1;
showImage();
}
}
function showNextImage() {
if (index < data.files.length - 1) {
index = index + 1;
showImage();
}
}
function handleKeyDown(e) {
if (document.activeElement === sidebarImageSettingPostInput || document.activeElement === sidebarImageSettingUploadURLInput) return;
switch (e.key) {
case "Escape":
if (sidebar.classList.contains("open")) {
closeSidebar();
} else {
window.history.back();
}
break;
case "ArrowRight":
showNextImage();
break;
case "ArrowLeft":
showPrevImage();
break;
case "b":
if (e.ctrlKey) {
toggleSidebar();
}
break;
}
}
function updateNavButtons() {
prevBtn.style.opacity = index <= 0 ? "0" : "1";
prevBtn.style.pointerEvents = index <= 0 ? "none" : "auto";
nextBtn.style.opacity = index >= data.files.length - 1 ? "0" : "1";
nextBtn.style.pointerEvents = index >= data.files.length - 1 ? "none" : "auto";
history.replaceState(null, null, `/artworks/${data.artwork.id}${index > 0 ? `/photo/${index}` : ""}`);
}
function updateInfoBar() {
sidebarImageAuthor.replaceChildren();
data.socials.forEach((social, index) => {
const author = document.createElement("p");
if (index > 0) {
author.style.borderTop = "1px solid #777";
}
const authorLink = document.createElement("a");
authorLink.textContent = `${social.platform}/${social.display_name}`;
authorLink.href = social.url;
authorLink.target = "_blank";
author.appendChild(authorLink);
sidebarImageAuthor.appendChild(author);
});
sidebarImageSource.replaceChildren();
data.posts.forEach((post, index) => {
const source = document.createElement("div");
if (index > 0) {
source.style.borderTop = "1px solid #777";
}
const link = document.createElement("a");
link.textContent = `${post.platform}/${post.post_id}`;
link.href = post.url;
link.target = "_blank";
source.appendChild(link);
if (post.time) {
const time = document.createElement("p");
time.classList.add("time");
time.textContent = post.time.substring(0, 19);
source.appendChild(time);
}
if (post.title) {
const title = document.createElement("p");
title.textContent = post.title;
source.appendChild(title);
}
if (post.text) {
const text = document.createElement("p");
text.classList.add("desc");
text.textContent = post.text;
// text.innerHTML = post.text;
source.appendChild(text);
}
sidebarImageSource.appendChild(source);
})
sidebarImageTag.replaceChildren();
data.tags.forEach(tag => {
const tagLink = document.createElement("a");
tagLink.textContent = `#${tag.text}`;
tagLink.href = `/artworks/?tags=${tag.id}`;
tagLink.target = "_blank";
sidebarImageTag.appendChild(tagLink);
})
const file = data.files[index];
const fileRes = document.createElement("p");
fileRes.textContent = `${file.width}*${file.height}px`;
const fileInfo = document.createElement("p");
fileInfo.textContent = `${file.size_str} · ${file.ext}`;
const id = document.createElement("p");
id.id = "img-id";
id.textContent = `${file.id_str} L${data.artwork.sensitive} ${data.artwork.private ? " (private)" : ""}`;
sidebarImageInfo.replaceChildren(fileRes, fileInfo, id);
if (document.cookie.includes("_trpic_user")) {
const a = document.createElement("a");
a.href = `/convert/${file.id_str}`;
a.target = "_blank";
a.textContent = `${file.quality ? `${file.quality}% ` : ""}Convert`;
sidebarImageInfo.appendChild(a);
}
sidebarImageSettingSselect.value = data.artwork.sensitive ? data.artwork.sensitive : 0;
sidebarImageSettingPselect.value = Boolean(data.artwork.private);
sidebarImageSettingPselect.style.backgroundColor = data.artwork.private ? "rgb(80, 70, 50)" : "rgb(50, 80, 50)";
}
function openSidebar() {
localStorage.setItem("sidebar_on", true);
sidebar.style.width = "30vw";
sidebar.classList.add("open");
navControls.classList.add("min-hide");
sidebarBtn.style.backgroundColor = "rgba(105, 105, 105, 0.3)";
}
function closeSidebar() {
localStorage.setItem("sidebar_on", false);
sidebar.style.width = "0px";
sidebar.classList.remove("open");
navControls.classList.remove("min-hide");
sidebarBtn.style.backgroundColor = "";
}
function toggleSidebar() {
if (sidebar.classList.contains("open")) {
closeSidebar();
} else {
openSidebar();
}
}
function downloadImage() {
const file = data.files[index];
const post = data.posts[0];
const link = document.createElement("a");
link.download = `${post.platform}_${post.post_id}_trpic_${file.id_str}.${file.ext}`;
link.href = `/files/${file.id_str}.${file.ext}`;
link.click();
link.remove();
}
function deleteImage() {
const file = data.files[index];
if (!confirm(`Are you sure you want to delete this ${file.id_str} image?`)) return;
fetch(`/api/files/${file.id_str}`, {
method: "DELETE",
})
.then((response) => {
if (response.ok) {
data.files.splice(index, 1);
index = 0;
showImage();
} else {
response.json().then((resjson) => {
if (resjson.code == 403) {
if (confirm(`This is last image of this artwork, are you want to delete this artwork?`)) deleteArtwork();
return
}
alert(`Failed to delete image: ${resjson.msg}`);
});
}
})
.catch((err) => {
console.error(err);
alert(`Failed to delete image: ${err}`);
});
}
function deleteArtwork() {
fetch(`/api/artworks/${data.artwork.id}`, {
method: "DELETE",
})
.then((response) => {
if (response.ok) {
window.location.href = "/";
} else {
response.json().then((resjson) => {
alert(`Failed to delete artwork: ${resjson.msg}`);
});
}
})
.catch((err) => {
console.error(err);
alert(`Failed to delete artwork: ${err}`);
});
}
});