diff --git a/Readme.md b/Readme.md index c212a64..5841d24 100644 --- a/Readme.md +++ b/Readme.md @@ -15,6 +15,10 @@ Add torrents, monitor downloads, and instantly stream videos through a clean web - Upload `.torrent` files (via form) - 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** - View active torrents - See progress, speed, and remaining time diff --git a/client/src/App.svelte b/client/src/App.svelte index 6e62aaf..d7621be 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -17,6 +17,7 @@ import { refreshTvShowCount } from "./stores/tvStore.js"; import { refreshMusicCount } from "./stores/musicStore.js"; import { fetchTrashItems } from "./stores/trashStore.js"; + import { setAvatarUrl } from "./stores/avatarStore.js"; const token = getAccessToken(); @@ -41,6 +42,34 @@ }, 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) const toggleMenu = () => { menuOpen = !menuOpen; @@ -57,6 +86,7 @@ refreshTvShowCount(); refreshMusicCount(); fetchTrashItems(); + loadUserProfile(); const authToken = getAccessToken(); if (authToken) { const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`; diff --git a/client/src/assets/avatar.png b/client/src/assets/avatar.png deleted file mode 100644 index af9a87d..0000000 Binary files a/client/src/assets/avatar.png and /dev/null differ diff --git a/client/src/components/Topbar.svelte b/client/src/components/Topbar.svelte index 84f2845..4aad0af 100644 --- a/client/src/components/Topbar.svelte +++ b/client/src/components/Topbar.svelte @@ -6,6 +6,7 @@ activeSearchTerm, updateSearchTerm } from "../stores/searchStore.js"; + import { avatarUrlStore } from "../stores/avatarStore.js"; import { clearTokens } from "../utils/api.js"; @@ -15,6 +16,7 @@ export let avatarUrl = null; let showAvatarMenu = false; let avatarWrap; + $: resolvedAvatar = avatarUrl || $avatarUrlStore; const onToggle = () => dispatch("toggleMenu"); @@ -82,8 +84,8 @@
-
- Profil içeriği yakında. + +
+
+
Kullanıcı
+ {#if loading} +

Yükleniyor...

+ {:else if error} +

{error}

+ {:else} +
+ Kullanıcı Adı + {profile.username} +
+
+ Rol + {profile.role} +
+
+ Avatar +
+ {#if avatarSrc} + Avatar (avatarSrc = null)} /> + {:else} +
+ +
+ {/if} +
+
+ {/if} +
+ +
+
Avatar Güncelle
+

+ Sadece jpg, jpeg veya png; maksimum 3MB. Kırpma sonrası PNG olarak kaydedilir. +

+
+ + +
+ + {#if showCropper} +
+
+ {#if previewImage} + Önizleme +
startDrag("move", e)} + > + + { e.stopPropagation(); startDrag("topleft", e); }}> + { e.stopPropagation(); startDrag("topright", e); }}> + { e.stopPropagation(); startDrag("bottomleft", e); }}> + { e.stopPropagation(); startDrag("bottomright", e); }}> + + + { e.stopPropagation(); startDrag("top", e); }}> + { e.stopPropagation(); startDrag("right", e); }}> + { e.stopPropagation(); startDrag("bottom", e); }}> + { e.stopPropagation(); startDrag("left", e); }}> +
+ {:else} +
+ +
+ {/if} +
+
+ + +
+
+ {/if} + + {#if uploadError} +

{uploadError}

+ {/if} + {#if successMessage} +

{successMessage}

+ {/if} +
@@ -33,10 +475,278 @@ gap: 8px; } - .empty { - padding: 24px; - border: 1px dashed var(--border, #dcdcdc); + .card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; + } + + .card { + background: #fff; + border: 1px solid var(--border, #dcdcdc); 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; + } } diff --git a/client/src/stores/avatarStore.js b/client/src/stores/avatarStore.js new file mode 100644 index 0000000..21597cb --- /dev/null +++ b/client/src/stores/avatarStore.js @@ -0,0 +1,7 @@ +import { writable } from "svelte/store"; + +export const avatarUrlStore = writable(null); + +export function setAvatarUrl(url) { + avatarUrlStore.set(url || null); +} diff --git a/client/src/utils/api.js b/client/src/utils/api.js index 2476206..a570303 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -97,6 +97,20 @@ export async function deleteFromTrash(trashName) { 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) { const res = await apiFetch("/api/file/move", { method: "POST", diff --git a/server/server.js b/server/server.js index 0865dbb..54ab360 100644 --- a/server/server.js +++ b/server/server.js @@ -90,6 +90,7 @@ const FFPROBE_MAX_BUFFER = Number(process.env.FFPROBE_MAX_BUFFER) > 0 ? Number(process.env.FFPROBE_MAX_BUFFER) : 10 * 1024 * 1024; +const AVATAR_PATH = path.join(__dirname, "..", "client", "src", "assets", "avatar.png"); app.use(cors()); app.use(express.json()); @@ -180,6 +181,19 @@ const { router: authRouter, requireAuth, requireRole, issueMediaToken, verifyTok 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({ ffmpegPath: "ffmpeg", ffprobePath: FFPROBE_PATH, @@ -201,6 +215,73 @@ buildHealthReport({ 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) { if (!pathSegment) return null; if (pathSegment.startsWith("http")) return pathSegment; @@ -505,6 +586,25 @@ function sanitizeRelative(relPath) { 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({ tracker, movieMatch,