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}
+

(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}
+

+
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,