Profile avatar resize crop eklendi. Hatalar fixlendi.
This commit is contained in:
@@ -15,6 +15,10 @@ Add torrents, monitor downloads, and instantly stream videos through a clean web
|
|||||||
- Upload `.torrent` files (via form)
|
- Upload `.torrent` files (via form)
|
||||||
- Add magnet links (via prompt)
|
- Add magnet links (via prompt)
|
||||||
|
|
||||||
|
- 👤 **Profil & Avatar**
|
||||||
|
- Profil bilgilerini görüntüle
|
||||||
|
- Avatarı güvenli şekilde yükle/kırp (PNG, 3MB sınır)
|
||||||
|
|
||||||
- 📥 **Download Management**
|
- 📥 **Download Management**
|
||||||
- View active torrents
|
- View active torrents
|
||||||
- See progress, speed, and remaining time
|
- See progress, speed, and remaining time
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { refreshTvShowCount } from "./stores/tvStore.js";
|
import { refreshTvShowCount } from "./stores/tvStore.js";
|
||||||
import { refreshMusicCount } from "./stores/musicStore.js";
|
import { refreshMusicCount } from "./stores/musicStore.js";
|
||||||
import { fetchTrashItems } from "./stores/trashStore.js";
|
import { fetchTrashItems } from "./stores/trashStore.js";
|
||||||
|
import { setAvatarUrl } from "./stores/avatarStore.js";
|
||||||
|
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
|
|
||||||
@@ -41,6 +42,34 @@
|
|||||||
}, 400);
|
}, 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadUserProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API}/api/profile`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${getAccessToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const profileData = await response.json();
|
||||||
|
if (profileData?.avatarExists) {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
const avatarUrl = `${API}/api/profile/avatar?token=${token}&v=${Date.now()}`;
|
||||||
|
setAvatarUrl(avatarUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Profil bilgileri yüklenemedi:', error);
|
||||||
|
setAvatarUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Menü aç/kapat (hamburger butonuyla)
|
// Menü aç/kapat (hamburger butonuyla)
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
menuOpen = !menuOpen;
|
menuOpen = !menuOpen;
|
||||||
@@ -57,6 +86,7 @@
|
|||||||
refreshTvShowCount();
|
refreshTvShowCount();
|
||||||
refreshMusicCount();
|
refreshMusicCount();
|
||||||
fetchTrashItems();
|
fetchTrashItems();
|
||||||
|
loadUserProfile();
|
||||||
const authToken = getAccessToken();
|
const authToken = getAccessToken();
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`;
|
const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -6,6 +6,7 @@
|
|||||||
activeSearchTerm,
|
activeSearchTerm,
|
||||||
updateSearchTerm
|
updateSearchTerm
|
||||||
} from "../stores/searchStore.js";
|
} from "../stores/searchStore.js";
|
||||||
|
import { avatarUrlStore } from "../stores/avatarStore.js";
|
||||||
|
|
||||||
import { clearTokens } from "../utils/api.js";
|
import { clearTokens } from "../utils/api.js";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
export let avatarUrl = null;
|
export let avatarUrl = null;
|
||||||
let showAvatarMenu = false;
|
let showAvatarMenu = false;
|
||||||
let avatarWrap;
|
let avatarWrap;
|
||||||
|
$: resolvedAvatar = avatarUrl || $avatarUrlStore;
|
||||||
|
|
||||||
const onToggle = () => dispatch("toggleMenu");
|
const onToggle = () => dispatch("toggleMenu");
|
||||||
|
|
||||||
@@ -82,8 +84,8 @@
|
|||||||
|
|
||||||
<div class="avatar-wrap" bind:this={avatarWrap}>
|
<div class="avatar-wrap" bind:this={avatarWrap}>
|
||||||
<button class="avatar" type="button" aria-label="Profil" on:click={toggleAvatarMenu}>
|
<button class="avatar" type="button" aria-label="Profil" on:click={toggleAvatarMenu}>
|
||||||
{#if avatarUrl}
|
{#if resolvedAvatar}
|
||||||
<img src={avatarUrl} alt="Avatar" loading="lazy" />
|
<img src={resolvedAvatar} alt="Avatar" loading="lazy" on:error={() => (resolvedAvatar = null)} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
<i class="fa-regular fa-user"></i>
|
<i class="fa-regular fa-user"></i>
|
||||||
|
|||||||
@@ -27,11 +27,13 @@
|
|||||||
persistTokens({ accessToken, refreshToken });
|
persistTokens({ accessToken, refreshToken });
|
||||||
if (accessToken) localStorage.setItem("token", accessToken); // Geçiş dönemi uyumluluğu
|
if (accessToken) localStorage.setItem("token", accessToken); // Geçiş dönemi uyumluluğu
|
||||||
if (user) localStorage.setItem("user", JSON.stringify(user));
|
if (user) localStorage.setItem("user", JSON.stringify(user));
|
||||||
// Router state beklemeden anında yönlendir
|
window.dispatchEvent(new Event("token-changed"));
|
||||||
navigate("/", { replace: true });
|
// Tam yenileme ile tüm store ve websocket'ler temiz açılır
|
||||||
|
window.location.replace("/");
|
||||||
} else {
|
} else {
|
||||||
error = "Kullanıcı adı veya şifre hatalı.";
|
error = "Kullanıcı adı veya şifre hatalı.";
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
window.dispatchEvent(new Event("token-changed"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,349 @@
|
|||||||
<script>
|
<script>
|
||||||
// Tasarım, diğer sayfalardaki yapıyı korur; şimdilik boş içerik.
|
import { onMount } from "svelte";
|
||||||
|
import { API, fetchProfile, uploadAvatar, getAccessToken } from "../utils/api.js";
|
||||||
|
import { setAvatarUrl } from "../stores/avatarStore.js";
|
||||||
|
|
||||||
|
const MAX_SIZE = 3 * 1024 * 1024;
|
||||||
|
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/jpg"];
|
||||||
|
|
||||||
|
let profile = { username: "", role: "" };
|
||||||
|
let loading = true;
|
||||||
|
let error = null;
|
||||||
|
let avatarSrc = null;
|
||||||
|
let successMessage = "";
|
||||||
|
let uploadError = "";
|
||||||
|
|
||||||
|
let fileInput;
|
||||||
|
let previewUrl = null;
|
||||||
|
let previewImage = null;
|
||||||
|
let imageSize = { width: 0, height: 0 };
|
||||||
|
let croppedPreview = null;
|
||||||
|
let saving = false;
|
||||||
|
let showCropper = false;
|
||||||
|
const DISPLAY_SIZE = 320;
|
||||||
|
const STAGE_SIZE = 320;
|
||||||
|
let stageEl;
|
||||||
|
|
||||||
|
// Dikdörtgen crop kutusu (görüntü alanı koordinatlarıyla)
|
||||||
|
let cropBox = { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
let dragState = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadProfile();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetchProfile();
|
||||||
|
profile = {
|
||||||
|
username: res?.username || "User",
|
||||||
|
role: res?.role || "user"
|
||||||
|
};
|
||||||
|
const hasAvatar = !!res?.avatarExists;
|
||||||
|
avatarSrc = hasAvatar ? buildAvatarUrl() : null;
|
||||||
|
setAvatarUrl(avatarSrc);
|
||||||
|
} catch (err) {
|
||||||
|
error = "Profil yüklenemedi.";
|
||||||
|
avatarSrc = null;
|
||||||
|
setAvatarUrl(null);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSelection() {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = null;
|
||||||
|
previewImage = null;
|
||||||
|
imageSize = { width: 0, height: 0 };
|
||||||
|
cropBox = { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
croppedPreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sniffSignature(bytes) {
|
||||||
|
if (!bytes || bytes.length < 4) return false;
|
||||||
|
const png = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
|
||||||
|
const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
||||||
|
return png || jpeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(event) {
|
||||||
|
uploadError = "";
|
||||||
|
successMessage = "";
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
resetSelection();
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes((file.type || "").toLowerCase())) {
|
||||||
|
uploadError = "Sadece jpg, jpeg veya png yükleyebilirsiniz.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
uploadError = "Dosya boyutu 3MB'ı aşmamalı.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const header = new Uint8Array(buffer.slice(0, 4));
|
||||||
|
if (!sniffSignature(header)) {
|
||||||
|
uploadError = "Geçerli bir görsel dosyası değil.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([buffer], { type: file.type || "image/png" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
previewUrl = url;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
previewImage = img;
|
||||||
|
imageSize = { width: img.width, height: img.height };
|
||||||
|
initializeCropBox();
|
||||||
|
buildPreview();
|
||||||
|
showCropper = true;
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
uploadError = "Görsel okunamadı.";
|
||||||
|
resetSelection();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(val, min, max) {
|
||||||
|
return Math.min(Math.max(val, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRenderMetrics() {
|
||||||
|
const scale = Math.min(DISPLAY_SIZE / imageSize.width, DISPLAY_SIZE / imageSize.height);
|
||||||
|
const renderedW = imageSize.width * scale;
|
||||||
|
const renderedH = imageSize.height * scale;
|
||||||
|
const offsetX = (DISPLAY_SIZE - renderedW) / 2;
|
||||||
|
const offsetY = (DISPLAY_SIZE - renderedH) / 2;
|
||||||
|
return { scale, renderedW, renderedH, offsetX, offsetY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeCropBox() {
|
||||||
|
const { renderedW, renderedH, offsetX, offsetY } = getRenderMetrics();
|
||||||
|
const maxSize = Math.min(renderedW, renderedH);
|
||||||
|
// Çerçeve maksimum boyutun %80'inde başlasın, minimum 100px
|
||||||
|
const size = Math.max(100, Math.min(maxSize * 0.8, 200));
|
||||||
|
cropBox = {
|
||||||
|
x: offsetX + (renderedW - size) / 2,
|
||||||
|
y: offsetY + (renderedH - size) / 2,
|
||||||
|
width: size,
|
||||||
|
height: size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCropBox(box) {
|
||||||
|
const { renderedW, renderedH, offsetX, offsetY } = getRenderMetrics();
|
||||||
|
const minSize = 40;
|
||||||
|
let width = Math.max(minSize, Math.min(box.width, renderedW));
|
||||||
|
let height = Math.max(minSize, Math.min(box.height, renderedH));
|
||||||
|
let x = box.x;
|
||||||
|
let y = box.y;
|
||||||
|
// Konteyner sınırları içinde tut
|
||||||
|
if (x < offsetX) x = offsetX;
|
||||||
|
if (y < offsetY) y = offsetY;
|
||||||
|
if (x + width > offsetX + renderedW) x = offsetX + renderedW - width;
|
||||||
|
if (y + height > offsetY + renderedH) y = offsetY + renderedH - height;
|
||||||
|
return { x, y, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreview() {
|
||||||
|
if (!previewImage || !cropBox.width || !cropBox.height) {
|
||||||
|
croppedPreview = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { scale, offsetX, offsetY } = getRenderMetrics();
|
||||||
|
const srcWidth = cropBox.width / scale;
|
||||||
|
const srcHeight = cropBox.height / scale;
|
||||||
|
const srcX = (cropBox.x - offsetX) / scale;
|
||||||
|
const srcY = (cropBox.y - offsetY) / scale;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const size = 256;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.drawImage(
|
||||||
|
previewImage,
|
||||||
|
srcX,
|
||||||
|
srcY,
|
||||||
|
srcWidth,
|
||||||
|
srcHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
size,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
croppedPreview = canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(handle, event) {
|
||||||
|
if (!previewImage) return;
|
||||||
|
event.preventDefault();
|
||||||
|
console.log('startDrag called with handle:', handle); // Debug
|
||||||
|
const { x, y } = toStageCoords(event);
|
||||||
|
dragState = {
|
||||||
|
handle,
|
||||||
|
startX: x,
|
||||||
|
startY: y,
|
||||||
|
box: { ...cropBox }
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", onDrag);
|
||||||
|
window.addEventListener("pointerup", endDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrag(event) {
|
||||||
|
if (!dragState) return;
|
||||||
|
const { x, y } = toStageCoords(event);
|
||||||
|
const dx = x - dragState.startX;
|
||||||
|
const dy = y - dragState.startY;
|
||||||
|
const { handle, box } = dragState;
|
||||||
|
console.log('onDrag with handle:', handle, 'dx:', dx, 'dy:', dy, 'current box:', { x: box.x, y: box.y, width: box.width, height: box.height }); // Debug
|
||||||
|
let next = { ...box };
|
||||||
|
const minSize = 40;
|
||||||
|
|
||||||
|
const tl = { x: box.x, y: box.y };
|
||||||
|
const br = { x: box.x + box.width, y: box.y + box.height };
|
||||||
|
const tr = { x: box.x + box.width, y: box.y };
|
||||||
|
const bl = { x: box.x, y: box.y + box.height };
|
||||||
|
|
||||||
|
const pos = { x, y };
|
||||||
|
|
||||||
|
switch (handle) {
|
||||||
|
case "move":
|
||||||
|
next.x = box.x + dx;
|
||||||
|
next.y = box.y + dy;
|
||||||
|
break;
|
||||||
|
case "topleft": {
|
||||||
|
const newWidth = Math.max(minSize, box.width - dx);
|
||||||
|
const newHeight = Math.max(minSize, box.height - dy);
|
||||||
|
next.x = box.x + (box.width - newWidth);
|
||||||
|
next.y = box.y + (box.height - newHeight);
|
||||||
|
next.width = newWidth;
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "topright": {
|
||||||
|
const newWidth = Math.max(minSize, box.width + dx);
|
||||||
|
const newHeight = Math.max(minSize, box.height - dy);
|
||||||
|
next.y = box.y + (box.height - newHeight);
|
||||||
|
next.width = newWidth;
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bottomleft": {
|
||||||
|
const newWidth = Math.max(minSize, box.width - dx);
|
||||||
|
const newHeight = Math.max(minSize, box.height + dy);
|
||||||
|
next.x = box.x + (box.width - newWidth);
|
||||||
|
next.width = newWidth;
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bottomright":
|
||||||
|
default: {
|
||||||
|
const newWidth = Math.max(minSize, box.width + dx);
|
||||||
|
const newHeight = Math.max(minSize, box.height + dy);
|
||||||
|
next.width = newWidth;
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Kenar handle'ları - tek yönlü boyutlandırma
|
||||||
|
case "top": {
|
||||||
|
const newHeight = Math.max(minSize, box.height - dy);
|
||||||
|
next.y = box.y + (box.height - newHeight);
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "right": {
|
||||||
|
const newWidth = Math.max(minSize, box.width + dx);
|
||||||
|
next.width = newWidth;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bottom": {
|
||||||
|
const newHeight = Math.max(minSize, box.height + dy);
|
||||||
|
next.height = newHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "left": {
|
||||||
|
const newWidth = Math.max(minSize, box.width - dx);
|
||||||
|
next.x = box.x + (box.width - newWidth);
|
||||||
|
next.width = newWidth;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cropBox = clampCropBox(next);
|
||||||
|
buildPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
window.removeEventListener("pointermove", onDrag);
|
||||||
|
window.removeEventListener("pointerup", endDrag);
|
||||||
|
dragState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStageCoords(event) {
|
||||||
|
const rect = stageEl?.getBoundingClientRect();
|
||||||
|
if (!rect) return { x: 0, y: 0 };
|
||||||
|
return {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAvatar() {
|
||||||
|
uploadError = "";
|
||||||
|
successMessage = "";
|
||||||
|
if (!previewImage || !croppedPreview) {
|
||||||
|
uploadError = "Önce bir görsel seçin ve kırpın.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const blob = await new Promise((resolve, reject) =>
|
||||||
|
fetch(croppedPreview)
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blob.size > MAX_SIZE) {
|
||||||
|
uploadError = "Kırpılan görsel 3MB sınırını aşıyor.";
|
||||||
|
saving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("avatar", blob, "avatar.png");
|
||||||
|
const res = await uploadAvatar(form);
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.error || "Avatar kaydedilemedi");
|
||||||
|
}
|
||||||
|
const nextUrl = buildAvatarUrl();
|
||||||
|
avatarSrc = nextUrl;
|
||||||
|
setAvatarUrl(nextUrl);
|
||||||
|
successMessage = "Avatar güncellendi.";
|
||||||
|
resetSelection();
|
||||||
|
if (fileInput) fileInput.value = "";
|
||||||
|
showCropper = false;
|
||||||
|
} catch (err) {
|
||||||
|
uploadError = err?.message || "Avatar yüklenirken hata oluştu.";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAvatarUrl() {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) return null;
|
||||||
|
return `${API}/api/profile/avatar?token=${token}&v=${Date.now()}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="files">
|
<section class="files">
|
||||||
@@ -8,8 +352,106 @@
|
|||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="empty">
|
|
||||||
Profil içeriği yakında.
|
<div class="card-grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Kullanıcı</div>
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Yükleniyor...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Kullanıcı Adı</span>
|
||||||
|
<span class="value">{profile.username}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Rol</span>
|
||||||
|
<span class="value role">{profile.role}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Avatar</span>
|
||||||
|
<div class="avatar-preview">
|
||||||
|
{#if avatarSrc}
|
||||||
|
<img src={avatarSrc} alt="Avatar" on:error={() => (avatarSrc = null)} />
|
||||||
|
{:else}
|
||||||
|
<div class="avatar-placeholder">
|
||||||
|
<i class="fa-regular fa-user"></i>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Avatar Güncelle</div>
|
||||||
|
<p class="muted small">
|
||||||
|
Sadece jpg, jpeg veya png; maksimum 3MB. Kırpma sonrası PNG olarak kaydedilir.
|
||||||
|
</p>
|
||||||
|
<div class="upload-row">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg"
|
||||||
|
on:change={handleFileChange}
|
||||||
|
bind:this={fileInput}
|
||||||
|
/>
|
||||||
|
<button class="ghost" type="button" on:click={() => fileInput && (fileInput.value = "")}>
|
||||||
|
Temizle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCropper}
|
||||||
|
<div class="crop-wrapper">
|
||||||
|
<div class="crop-stage" bind:this={stageEl}>
|
||||||
|
{#if previewImage}
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Önizleme"
|
||||||
|
draggable="false"
|
||||||
|
style={`width:${DISPLAY_SIZE}px;height:${DISPLAY_SIZE}px;`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="crop-box"
|
||||||
|
style={`width:${cropBox.width}px;height:${cropBox.height}px;transform:translate(${cropBox.x}px, ${cropBox.y}px);`}
|
||||||
|
on:pointerdown={(e) => startDrag("move", e)}
|
||||||
|
>
|
||||||
|
<!-- Köşe handle'ları -->
|
||||||
|
<span class="handle tl" on:pointerdown={(e) => { e.stopPropagation(); startDrag("topleft", e); }}></span>
|
||||||
|
<span class="handle tr" on:pointerdown={(e) => { e.stopPropagation(); startDrag("topright", e); }}></span>
|
||||||
|
<span class="handle bl" on:pointerdown={(e) => { e.stopPropagation(); startDrag("bottomleft", e); }}></span>
|
||||||
|
<span class="handle br" on:pointerdown={(e) => { e.stopPropagation(); startDrag("bottomright", e); }}></span>
|
||||||
|
|
||||||
|
<!-- Kenar handle'ları -->
|
||||||
|
<span class="handle top" on:pointerdown={(e) => { e.stopPropagation(); startDrag("top", e); }}></span>
|
||||||
|
<span class="handle right" on:pointerdown={(e) => { e.stopPropagation(); startDrag("right", e); }}></span>
|
||||||
|
<span class="handle bottom" on:pointerdown={(e) => { e.stopPropagation(); startDrag("bottom", e); }}></span>
|
||||||
|
<span class="handle left" on:pointerdown={(e) => { e.stopPropagation(); startDrag("left", e); }}></span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="avatar-placeholder large">
|
||||||
|
<i class="fa-regular fa-user"></i>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="ghost" type="button" on:click={() => { resetSelection(); showCropper = false; if (fileInput) fileInput.value = ""; }}>
|
||||||
|
Vazgeç
|
||||||
|
</button>
|
||||||
|
<button class="primary" type="button" on:click={saveAvatar} disabled={saving}>
|
||||||
|
{saving ? "Kaydediliyor..." : "Kırp ve Kaydet"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if uploadError}
|
||||||
|
<p class="error">{uploadError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if successMessage}
|
||||||
|
<p class="success">{successMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -33,10 +475,278 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.card-grid {
|
||||||
padding: 24px;
|
display: grid;
|
||||||
border: 1px dashed var(--border, #dcdcdc);
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border, #dcdcdc);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #666;
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22304e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border, #e4e4e4);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #5c5f6a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #0e7490;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview img,
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--border, #dcdcdc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row input[type="file"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid var(--border, #dcdcdc);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-controls label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
background: #2f3035;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border, #dcdcdc);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-stage img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-box {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border: 1px solid #000;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: move;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-box .handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #2f3035;
|
||||||
|
border-radius: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle.tl { top: -8px; left: -8px; cursor: nwse-resize !important; }
|
||||||
|
.handle.tr { top: -8px; right: -8px; cursor: nesw-resize !important; }
|
||||||
|
.handle.bl { bottom: -8px; left: -8px; cursor: nesw-resize !important; }
|
||||||
|
.handle.br { bottom: -8px; right: -8px; cursor: nwse-resize !important; }
|
||||||
|
|
||||||
|
/* Kenar handle'ları */
|
||||||
|
.handle.top {
|
||||||
|
top: -3px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
height: 6px;
|
||||||
|
width: auto;
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle.right {
|
||||||
|
right: -3px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 6px;
|
||||||
|
height: auto;
|
||||||
|
cursor: ew-resize !important;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle.bottom {
|
||||||
|
bottom: -3px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
height: 6px;
|
||||||
|
width: auto;
|
||||||
|
cursor: ns-resize !important;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle.left {
|
||||||
|
left: -3px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 6px;
|
||||||
|
height: auto;
|
||||||
|
cursor: ew-resize !important;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder.large {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
font-size: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-controls input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #22304e;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.crop-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.upload-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
7
client/src/stores/avatarStore.js
Normal file
7
client/src/stores/avatarStore.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const avatarUrlStore = writable(null);
|
||||||
|
|
||||||
|
export function setAvatarUrl(url) {
|
||||||
|
avatarUrlStore.set(url || null);
|
||||||
|
}
|
||||||
@@ -97,6 +97,20 @@ export async function deleteFromTrash(trashName) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 👤 Profil
|
||||||
|
export async function fetchProfile() {
|
||||||
|
const res = await apiFetch("/api/profile");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(formData) {
|
||||||
|
const res = await apiFetch("/api/profile/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function moveEntry(sourcePath, targetDirectory) {
|
export async function moveEntry(sourcePath, targetDirectory) {
|
||||||
const res = await apiFetch("/api/file/move", {
|
const res = await apiFetch("/api/file/move", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
100
server/server.js
100
server/server.js
@@ -90,6 +90,7 @@ const FFPROBE_MAX_BUFFER =
|
|||||||
Number(process.env.FFPROBE_MAX_BUFFER) > 0
|
Number(process.env.FFPROBE_MAX_BUFFER) > 0
|
||||||
? Number(process.env.FFPROBE_MAX_BUFFER)
|
? Number(process.env.FFPROBE_MAX_BUFFER)
|
||||||
: 10 * 1024 * 1024;
|
: 10 * 1024 * 1024;
|
||||||
|
const AVATAR_PATH = path.join(__dirname, "..", "client", "src", "assets", "avatar.png");
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -180,6 +181,19 @@ const { router: authRouter, requireAuth, requireRole, issueMediaToken, verifyTok
|
|||||||
|
|
||||||
app.use(authRouter);
|
app.use(authRouter);
|
||||||
|
|
||||||
|
const avatarUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 3 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const type = (file.mimetype || "").toLowerCase();
|
||||||
|
const allowed = ["image/png", "image/jpeg", "image/jpg"];
|
||||||
|
if (!allowed.includes(type)) {
|
||||||
|
return cb(new Error("INVALID_FILE_TYPE"));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
buildHealthReport({
|
buildHealthReport({
|
||||||
ffmpegPath: "ffmpeg",
|
ffmpegPath: "ffmpeg",
|
||||||
ffprobePath: FFPROBE_PATH,
|
ffprobePath: FFPROBE_PATH,
|
||||||
@@ -201,6 +215,73 @@ buildHealthReport({
|
|||||||
|
|
||||||
app.get("/api/health", requireAuth, healthRouter(() => healthSnapshot));
|
app.get("/api/health", requireAuth, healthRouter(() => healthSnapshot));
|
||||||
|
|
||||||
|
// --- Profil bilgisi ---
|
||||||
|
app.get("/api/profile", requireAuth, (req, res) => {
|
||||||
|
const username = req.user?.sub || req.user?.username || "user";
|
||||||
|
const role = req.user?.role || "user";
|
||||||
|
const avatarExists = fs.existsSync(AVATAR_PATH);
|
||||||
|
res.json({
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
avatarExists,
|
||||||
|
avatarUrl: avatarExists ? "/api/profile/avatar" : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/profile/avatar", requireAuth, (req, res) => {
|
||||||
|
if (!fs.existsSync(AVATAR_PATH)) {
|
||||||
|
return res.status(404).json({ error: "Avatar bulunamadı" });
|
||||||
|
}
|
||||||
|
res.setHeader("Content-Type", "image/png");
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
fs.createReadStream(AVATAR_PATH).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/profile/avatar",
|
||||||
|
requireAuth,
|
||||||
|
(req, res, next) => {
|
||||||
|
avatarUpload.single("avatar")(req, res, (err) => {
|
||||||
|
if (err) {
|
||||||
|
const isSize = err.code === "LIMIT_FILE_SIZE";
|
||||||
|
const message = isSize
|
||||||
|
? "Dosya boyutu 3MB'ı aşmamalı."
|
||||||
|
: "Geçersiz dosya tipi. Sadece jpg, jpeg veya png.";
|
||||||
|
return res.status(400).json({ error: message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file?.buffer) {
|
||||||
|
return res.status(400).json({ error: "Dosya yüklenemedi" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = req.file.buffer;
|
||||||
|
if (!isAllowedImage(buffer)) {
|
||||||
|
return res.status(400).json({ error: "Sadece jpeg/jpg/png kabul edilir" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPng(buffer)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Lütfen kırptıktan sonra PNG olarak yükleyin." });
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirForFile(AVATAR_PATH);
|
||||||
|
fs.writeFileSync(AVATAR_PATH, buffer);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
avatarUrl: "/api/profile/avatar"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Avatar yükleme hatası:", err);
|
||||||
|
res.status(500).json({ error: "Avatar kaydedilemedi" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function tvdbImageUrl(pathSegment) {
|
function tvdbImageUrl(pathSegment) {
|
||||||
if (!pathSegment) return null;
|
if (!pathSegment) return null;
|
||||||
if (pathSegment.startsWith("http")) return pathSegment;
|
if (pathSegment.startsWith("http")) return pathSegment;
|
||||||
@@ -505,6 +586,25 @@ function sanitizeRelative(relPath) {
|
|||||||
return relPath.replace(/^[\\/]+/, "");
|
return relPath.replace(/^[\\/]+/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPng(buffer) {
|
||||||
|
return (
|
||||||
|
buffer &&
|
||||||
|
buffer.length >= 8 &&
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJpeg(buffer) {
|
||||||
|
return buffer && buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedImage(buffer) {
|
||||||
|
return isPng(buffer) || isJpeg(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
function determineMediaType({
|
function determineMediaType({
|
||||||
tracker,
|
tracker,
|
||||||
movieMatch,
|
movieMatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user