first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:16:43 +03:00
commit 6d95e27114
97 changed files with 15687 additions and 0 deletions

109
lib/api/fetchClient.ts Normal file
View File

@@ -0,0 +1,109 @@
import Cookies from "js-cookie";
// .env'deki NEXT_PUBLIC_API_BASE kullanılır (örn: http://127.0.0.1:8080). /v1 prefix'i burada eklenir.
const getBaseUrl = () => {
const base = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8080";
const normalized = base.replace(/\/$/, "");
return `${normalized}/v1`;
};
const BASE_URL = getBaseUrl();
interface FetchOptions extends RequestInit {
headers?: Record<string, string>;
}
interface AuthResponse {
access_token: string;
refresh_token: string;
}
export const fetchClient = async (endpoint: string, options: FetchOptions = {}) => {
const getAccessToken = () => Cookies.get("access_token");
const getRefreshToken = () => Cookies.get("refresh_token");
const setTokens = (access: string, refresh: string) => {
Cookies.set("access_token", access, { secure: true, sameSite: 'strict' });
Cookies.set("refresh_token", refresh, { secure: true, sameSite: 'strict' });
// Dispatch event for other tabs or parts of the app to know (optional)
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("storage"));
}
};
const clearTokens = () => {
Cookies.remove("access_token");
Cookies.remove("refresh_token");
if (typeof window !== "undefined") {
try {
localStorage.removeItem("user");
} catch (e) { }
window.location.href = "/login";
}
};
const headers: Record<string, string> = {
...options.headers,
};
if (!(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
const token = getAccessToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const config: RequestInit = {
...options,
headers,
};
let response = await fetch(`${BASE_URL}${endpoint}`, config);
// Handle 401 - Token Expired
if (response.status === 401) {
const refreshToken = getRefreshToken();
if (!refreshToken) {
clearTokens();
throw new Error("Session expired");
}
try {
// Attempt to refresh token
const refreshResponse = await fetch(`${BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!refreshResponse.ok) {
throw new Error("Refresh failed");
}
const data: AuthResponse = await refreshResponse.json();
setTokens(data.access_token, data.refresh_token);
// Retry original request with new token
headers["Authorization"] = `Bearer ${data.access_token}`;
response = await fetch(`${BASE_URL}${endpoint}`, { ...options, headers });
} catch (error) {
clearTokens();
throw new Error("Session expired. Please login again.");
}
}
// Handle other errors
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = errorData.error || errorData.message || response.statusText;
throw new Error(errorMessage);
}
// Return json if content type is json, otherwise text or null
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return response.json();
}
return response.text();
};

View File

@@ -0,0 +1,300 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { fetchClient } from "@/lib/api/fetchClient";
import Cookies from "js-cookie";
// Types
export interface Permission {
id: number;
name: string;
description: string;
}
export interface Role {
id: number;
name: string;
description: string;
permissions?: Permission[] | null;
}
export interface SocialAccount {
id: number;
provider: string;
email: string;
}
export interface User {
id: string;
username: string;
email: string;
roles: Role[];
avatar_url?: string;
email_verified?: boolean;
is_oauth_user?: boolean;
social_accounts?: SocialAccount[];
created_at?: string;
updated_at?: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
interface LoginResponse {
user_id: string;
username: string;
email: string;
access_token: string;
refresh_token: string;
roles: Role[];
avatar?: string;
}
interface RegisterResponse {
user_id: string;
username: string;
email: string;
avatar: string;
email_verified: boolean;
message: string;
roles: Role[];
verification_token: string;
}
// Initial State
const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
// Async Thunks
export const login = createAsyncThunk(
"auth/login",
async (credentials: any, { rejectWithValue }) => {
try {
const response: LoginResponse = await fetchClient("/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
});
// Validate Check: Ensure tokens exist
if (!response.access_token || !response.refresh_token) {
return rejectWithValue("Giriş başarısız: Token alınamadı. Lütfen emailinizi doğruladığınızdan emin olun.");
}
// Store tokens in cookies
Cookies.set("access_token", response.access_token, { secure: true, sameSite: 'strict' });
Cookies.set("refresh_token", response.refresh_token, { secure: true, sameSite: 'strict' });
// Store user info in localStorage for convenience (optional, can be removed if strict cookie only)
if (typeof window !== "undefined") {
localStorage.setItem("user", JSON.stringify({
id: response.user_id,
username: response.username,
email: response.email,
roles: response.roles,
avatar_url: response.avatar
}));
}
return response;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const register = createAsyncThunk(
"auth/register",
async (credentials: any, { rejectWithValue }) => {
try {
const response: RegisterResponse = await fetchClient("/auth/register", {
method: "POST",
body: JSON.stringify(credentials),
});
// NOTE: Register does NOT return tokens anymore.
// We do NOT set cookies here.
// We do NOT set localStorage user here.
return response;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const fetchProfile = createAsyncThunk(
"auth/fetchProfile",
async (_, { rejectWithValue }) => {
try {
const response = await fetchClient("/profile");
const data = response as any;
// Map API response to User type, specifically avatar -> avatar_url
return {
...data,
avatar_url: data.avatar,
roles: data.roles || []
} as User;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const updateProfile = createAsyncThunk(
"auth/updateProfile",
async (formData: FormData, { rejectWithValue }) => {
try {
const response = await fetchClient("/profile", {
method: "PUT",
body: formData,
});
const data = (response as any).user;
// Map API response to User type, specifically avatar -> avatar_url
return {
...data,
avatar_url: data.avatar,
roles: data.roles || []
} as User;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const changePassword = createAsyncThunk(
"auth/changePassword",
async (data: any, { rejectWithValue }) => {
try {
const response = await fetchClient("/profile/password", {
method: "PUT",
body: JSON.stringify(data),
});
return response;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const changeEmail = createAsyncThunk(
"auth/changeEmail",
async (data: any, { rejectWithValue }) => {
try {
const response = await fetchClient("/profile/email", {
method: "PUT",
body: JSON.stringify(data),
});
return response;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
// Slice
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
logout: (state) => {
state.user = null;
state.isAuthenticated = false;
state.error = null;
Cookies.remove("access_token");
Cookies.remove("refresh_token");
if (typeof window !== "undefined") {
localStorage.removeItem("user");
}
},
restoreSession: (state) => {
if (typeof window !== "undefined") {
const userStr = localStorage.getItem("user");
const token = Cookies.get("access_token");
if (userStr && token) {
try {
const parsedUser = JSON.parse(userStr);
// Migration for legacy data: map avatar to avatar_url if needed
if (parsedUser.avatar && !parsedUser.avatar_url) {
parsedUser.avatar_url = parsedUser.avatar;
}
state.user = parsedUser;
state.isAuthenticated = true;
} catch (e) {
// Corrupt user data
localStorage.removeItem("user");
Cookies.remove("access_token");
Cookies.remove("refresh_token");
}
}
}
}
},
extraReducers: (builder) => {
// Login
builder.addCase(login.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(login.fulfilled, (state, action) => {
state.isLoading = false;
state.isAuthenticated = true;
state.user = {
id: action.payload.user_id,
username: action.payload.username,
email: action.payload.email,
roles: action.payload.roles,
avatar_url: action.payload.avatar
};
});
builder.addCase(login.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Register
builder.addCase(register.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(register.fulfilled, (state, action) => {
state.isLoading = false;
// isAuthenticated remains false because we need email verification
state.isAuthenticated = false;
// We do not set the user state effectively until they login
state.user = null;
});
builder.addCase(register.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch Profile
builder.addCase(fetchProfile.fulfilled, (state, action) => {
state.user = action.payload;
// Sync with localStorage so next refresh has fresh data
if (typeof window !== "undefined") {
localStorage.setItem("user", JSON.stringify(action.payload));
}
});
// Update Profile
builder.addCase(updateProfile.fulfilled, (state, action) => {
state.user = action.payload;
// Sync with localStorage so next refresh has fresh data
if (typeof window !== "undefined") {
localStorage.setItem("user", JSON.stringify(action.payload));
}
});
},
});
export const { logout, restoreSession } = authSlice.actions;
export default authSlice.reducer;

View File

@@ -0,0 +1,215 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { fetchClient } from "@/lib/api/fetchClient";
// Types
export interface CorsEntry {
id: string;
origin: string;
description?: string; // For whitelist
reason?: string; // For blacklist
is_active: boolean;
created_by: string;
created_at: string;
updated_at: string;
}
interface CorsState {
whitelist: CorsEntry[];
blacklist: CorsEntry[];
isLoading: boolean;
error: string | null;
}
const initialState: CorsState = {
whitelist: [],
blacklist: [],
isLoading: false,
error: null,
};
// Async Thunks
// --- WHITELIST ---
export const fetchWhitelists = createAsyncThunk(
"cors/fetchWhitelists",
async (_, { rejectWithValue }) => {
try {
return await fetchClient("/settings/cors/whitelist");
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const createWhitelist = createAsyncThunk(
"cors/createWhitelist",
async (data: { origin: string; description: string }, { rejectWithValue }) => {
try {
return await fetchClient("/settings/cors/whitelist", {
method: "POST",
body: JSON.stringify(data),
});
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const updateWhitelist = createAsyncThunk(
"cors/updateWhitelist",
async ({ id, data }: { id: string; data: Partial<CorsEntry> }, { rejectWithValue }) => {
try {
await fetchClient(`/settings/cors/whitelist/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
return { id, data };
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const deleteWhitelist = createAsyncThunk(
"cors/deleteWhitelist",
async (id: string, { rejectWithValue }) => {
try {
await fetchClient(`/settings/cors/whitelist/${id}`, {
method: "DELETE",
});
return id;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
// --- BLACKLIST ---
export const fetchBlacklists = createAsyncThunk(
"cors/fetchBlacklists",
async (_, { rejectWithValue }) => {
try {
return await fetchClient("/settings/cors/blacklist");
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const createBlacklist = createAsyncThunk(
"cors/createBlacklist",
async (data: { origin: string; reason: string }, { rejectWithValue }) => {
try {
return await fetchClient("/settings/cors/blacklist", {
method: "POST",
body: JSON.stringify(data),
});
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const updateBlacklist = createAsyncThunk(
"cors/updateBlacklist",
async ({ id, data }: { id: string; data: Partial<CorsEntry> }, { rejectWithValue }) => {
try {
await fetchClient(`/settings/cors/blacklist/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
return { id, data };
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const deleteBlacklist = createAsyncThunk(
"cors/deleteBlacklist",
async (id: string, { rejectWithValue }) => {
try {
await fetchClient(`/settings/cors/blacklist/${id}`, {
method: "DELETE",
});
return id;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
const corsSlice = createSlice({
name: "cors",
initialState,
reducers: {},
extraReducers: (builder) => {
// Fetch Whitelists
builder.addCase(fetchWhitelists.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchWhitelists.fulfilled, (state, action) => {
state.isLoading = false;
state.whitelist = action.payload as CorsEntry[];
});
builder.addCase(fetchWhitelists.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create Whitelist
builder.addCase(createWhitelist.fulfilled, (state, action) => {
state.whitelist.push(action.payload as CorsEntry);
});
// Update Whitelist
builder.addCase(updateWhitelist.fulfilled, (state, action) => {
const index = state.whitelist.findIndex((item) => item.id === action.payload.id);
if (index !== -1) {
state.whitelist[index] = { ...state.whitelist[index], ...action.payload.data };
}
});
// Delete Whitelist
builder.addCase(deleteWhitelist.fulfilled, (state, action) => {
state.whitelist = state.whitelist.filter((item) => item.id !== action.payload);
});
// Fetch Blacklists
builder.addCase(fetchBlacklists.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchBlacklists.fulfilled, (state, action) => {
state.isLoading = false;
state.blacklist = action.payload as CorsEntry[];
});
builder.addCase(fetchBlacklists.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Create Blacklist
builder.addCase(createBlacklist.fulfilled, (state, action) => {
state.blacklist.push(action.payload as CorsEntry);
});
// Update Blacklist
builder.addCase(updateBlacklist.fulfilled, (state, action) => {
const index = state.blacklist.findIndex((item) => item.id === action.payload.id);
if (index !== -1) {
state.blacklist[index] = { ...state.blacklist[index], ...action.payload.data };
}
});
// Delete Blacklist
builder.addCase(deleteBlacklist.fulfilled, (state, action) => {
state.blacklist = state.blacklist.filter((item) => item.id !== action.payload);
});
},
});
export default corsSlice.reducer;

View File

@@ -0,0 +1,256 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
// Users Slice for managing users and deleted users
import { fetchClient } from "@/lib/api/fetchClient";
import { User } from "@/lib/features/auth/authSlice";
/** API'den gelen kullanıcıyı frontend User tipine çevirir (avatar -> avatar_url, permissions null ise []). */
function mapApiUserToUser(apiUser: Record<string, unknown>): User & { deleted_at?: string } {
const roles = (apiUser.roles as User["roles"]) || [];
return {
id: String(apiUser.id),
username: String(apiUser.username ?? ""),
email: String(apiUser.email ?? ""),
roles: roles.map((r) => ({ ...r, permissions: r.permissions ?? [] })),
avatar_url: apiUser.avatar ? String(apiUser.avatar) : (apiUser.avatar_url as string | undefined),
deleted_at: apiUser.deleted_at ? String(apiUser.deleted_at) : undefined,
};
}
// Types
export interface CreateUserRequest {
username: string;
email: string;
password?: string;
roles?: string[];
avatar?: File;
}
export interface UpdateUserRequest {
id: string;
username?: string;
email?: string;
password?: string;
roles?: string[];
avatar?: File;
}
interface UsersState {
users: User[]; // Active users
deletedUsers: (User & { deleted_at?: string })[]; // Soft-deleted users
isLoading: boolean;
error: string | null;
}
const initialState: UsersState = {
users: [],
deletedUsers: [],
isLoading: false,
error: null,
};
// Async Thunks
export const fetchUsers = createAsyncThunk(
"users/fetchAll",
async (_, { rejectWithValue }) => {
try {
const response = await fetchClient("/admin/users") as { users?: unknown[]; data?: unknown[]; pagination?: unknown };
const rawList = response.users ?? response.data ?? (Array.isArray(response) ? response : null);
const list = Array.isArray(rawList) ? rawList : [];
return list.map((u) => mapApiUserToUser(u as Record<string, unknown>));
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const fetchDeletedUsers = createAsyncThunk(
"users/fetchDeleted",
async (_, { rejectWithValue }) => {
try {
const response = await fetchClient("/admin/users/deleted") as { users?: unknown[]; data?: unknown[]; pagination?: unknown };
const rawList = response.users ?? response.data ?? (Array.isArray(response) ? response : null);
const list = Array.isArray(rawList) ? rawList : [];
return list.map((u) => mapApiUserToUser(u as Record<string, unknown>));
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const createUser = createAsyncThunk(
"users/create",
async (userData: CreateUserRequest, { rejectWithValue }) => {
try {
const formData = new FormData();
formData.append("user_name", userData.username);
formData.append("email", userData.email);
if (userData.password) formData.append("password", userData.password);
if (userData.roles && userData.roles.length > 0) {
userData.roles.forEach((r) => formData.append("roles", r));
}
if (userData.avatar) {
formData.append("avatar", userData.avatar);
}
const response = await fetchClient("/admin/users", {
method: "POST",
body: formData,
});
const raw = (response as { user?: Record<string, unknown> }).user ?? response;
return mapApiUserToUser(raw as Record<string, unknown>);
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const updateUser = createAsyncThunk(
"users/update",
async (userData: UpdateUserRequest, { rejectWithValue }) => {
try {
const { id, username, email, password, roles, avatar } = userData;
const formData = new FormData();
if (username !== undefined) formData.append("user_name", username);
if (email !== undefined) formData.append("email", email);
if (password) formData.append("password", password);
if (roles && roles.length > 0) {
roles.forEach((r) => formData.append("roles", r));
}
if (avatar) {
formData.append("avatar", avatar);
}
const response = await fetchClient(`/admin/users/${id}`, {
method: "PUT",
body: formData,
});
const raw = (response as { user?: Record<string, unknown> }).user ?? response;
return mapApiUserToUser(raw as Record<string, unknown>);
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const deleteUser = createAsyncThunk(
"users/delete",
async (payload: string | { id: string; hard: boolean }, { rejectWithValue }) => {
try {
const id = typeof payload === "string" ? payload : payload.id;
const isHard = typeof payload === "object" && payload.hard;
const endpoint = `/admin/users/${id}${isHard ? "?hard=true" : ""}`;
await fetchClient(endpoint, {
method: "DELETE",
});
return { id, isHard };
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
export const restoreUser = createAsyncThunk(
"users/restore",
async (id: string, { rejectWithValue }) => {
try {
await fetchClient(`/admin/users/${id}/restore`, {
method: "POST",
});
return id;
} catch (error: any) {
return rejectWithValue(error.message);
}
}
);
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {},
extraReducers: (builder) => {
// Fetch Active
builder.addCase(fetchUsers.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.isLoading = false;
state.users = action.payload;
});
builder.addCase(fetchUsers.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
// Fetch Deleted
builder.addCase(fetchDeletedUsers.pending, (state) => {
state.isLoading = true;
state.error = null;
});
builder.addCase(fetchDeletedUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.isLoading = false;
state.deletedUsers = action.payload;
});
// Create
builder.addCase(createUser.fulfilled, (state, action: PayloadAction<User>) => {
state.isLoading = false;
if (action.payload && action.payload.id) {
state.users.push(action.payload);
}
});
// Update
builder.addCase(updateUser.fulfilled, (state, action: PayloadAction<User>) => {
state.isLoading = false;
if (action.payload && action.payload.id) {
const index = state.users.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.users[index] = action.payload;
}
}
});
// Delete
builder.addCase(deleteUser.fulfilled, (state, action: PayloadAction<{ id: string, isHard: boolean }>) => {
state.isLoading = false;
const { id, isHard } = action.payload;
// Remove from active users list (always happens)
const deletedUser = state.users.find(u => u.id === id);
state.users = state.users.filter(u => u.id !== id);
// If Soft Delete, add to deletedUsers list (mechanically we should re-fetch, but optimistically we can add if we had the full object)
if (!isHard && deletedUser) {
state.deletedUsers.unshift({ ...deletedUser, deleted_at: new Date().toISOString() });
}
// If Hard Delete, remove from deletedUsers list too (checking if it was there)
if (isHard) {
state.deletedUsers = state.deletedUsers.filter(u => u.id !== id);
}
});
// Restore
builder.addCase(restoreUser.fulfilled, (state, action: PayloadAction<string>) => {
const id = action.payload;
const restoredUser = state.deletedUsers.find(u => u.id === id);
// Remove from deleted list
state.deletedUsers = state.deletedUsers.filter(u => u.id !== id);
// Add to active list
if (restoredUser) {
const { deleted_at, ...user } = restoredUser;
state.users.push(user);
}
});
},
});
export default usersSlice.reducer;

8
lib/hooks.ts Normal file
View File

@@ -0,0 +1,8 @@
import { useDispatch, useSelector, useStore } from "react-redux";
import type { TypedUseSelectorHook } from "react-redux";
import type { AppDispatch, AppStore, RootState } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppStore: () => AppStore = useStore;

8
lib/schemas/login.ts Normal file
View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email("Geçerli bir email adresi giriniz"),
password: z.string().min(1, "Şifre alanı zorunludur"),
});
export type LoginSchema = z.infer<typeof loginSchema>;

13
lib/schemas/register.ts Normal file
View File

@@ -0,0 +1,13 @@
import { z } from "zod";
export const registerSchema = z.object({
username: z.string().min(3, "Kullanıcı adı en az 3 karakter olmalıdır"),
email: z.string().email("Geçerli bir email adresi giriniz"),
password: z.string().min(6, "Şifre en az 6 karakter olmalıdır"),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Şifreler eşleşmiyor",
path: ["confirmPassword"],
});
export type RegisterSchema = z.infer<typeof registerSchema>;

20
lib/store.ts Normal file
View File

@@ -0,0 +1,20 @@
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./features/auth/authSlice";
import usersReducer from "./features/users/usersSlice";
import corsReducer from "./features/cors/corsSlice";
export const makeStore = () => {
return configureStore({
reducer: {
auth: authReducer,
users: usersReducer,
cors: corsReducer,
},
});
};
// Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore>;
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore["getState"]>;
export type AppDispatch = AppStore["dispatch"];

15
lib/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Backend'den gelen avatar_url tam veya relative olabilir; görüntülenebilir URL döner. */
export function getAvatarUrl(avatarUrl: string | undefined): string | undefined {
if (!avatarUrl) return undefined
if (avatarUrl.startsWith("http://") || avatarUrl.startsWith("https://")) return avatarUrl
const base = process.env.NEXT_PUBLIC_API_BASE || ""
const normalized = base.replace(/\/$/, "")
return `${normalized}${avatarUrl.startsWith("/") ? "" : "/"}${avatarUrl}`
}