Profile avatar resize crop eklendi. Hatalar fixlendi.
This commit is contained in:
@@ -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}`;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -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 @@
|
||||
|
||||
<div class="avatar-wrap" bind:this={avatarWrap}>
|
||||
<button class="avatar" type="button" aria-label="Profil" on:click={toggleAvatarMenu}>
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="Avatar" loading="lazy" />
|
||||
{#if resolvedAvatar}
|
||||
<img src={resolvedAvatar} alt="Avatar" loading="lazy" on:error={() => (resolvedAvatar = null)} />
|
||||
{:else}
|
||||
<div class="placeholder">
|
||||
<i class="fa-regular fa-user"></i>
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
persistTokens({ accessToken, refreshToken });
|
||||
if (accessToken) localStorage.setItem("token", accessToken); // Geçiş dönemi uyumluluğu
|
||||
if (user) localStorage.setItem("user", JSON.stringify(user));
|
||||
// Router state beklemeden anında yönlendir
|
||||
navigate("/", { replace: true });
|
||||
window.dispatchEvent(new Event("token-changed"));
|
||||
// Tam yenileme ile tüm store ve websocket'ler temiz açılır
|
||||
window.location.replace("/");
|
||||
} else {
|
||||
error = "Kullanıcı adı veya şifre hatalı.";
|
||||
clearTokens();
|
||||
window.dispatchEvent(new Event("token-changed"));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,349 @@
|
||||
<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>
|
||||
|
||||
<section class="files">
|
||||
@@ -8,8 +352,106 @@
|
||||
<h2>Profile</h2>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</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();
|
||||
}
|
||||
|
||||
// 👤 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",
|
||||
|
||||
Reference in New Issue
Block a user