Merge pull request 'personal account' (#2) from feature/personal-account into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 33s
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 33s
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -63,6 +63,8 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::invite_link::delete_invite_link,
|
||||
routes::invite_link::validate_invite_link,
|
||||
routes::invite_link::join_family_via_invite,
|
||||
routes::user::leave_family,
|
||||
routes::user::get_family_members,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
@@ -94,6 +96,8 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::invite_link::InviteLinkResponse,
|
||||
routes::invite_link::ValidateInviteResponse,
|
||||
routes::invite_link::JoinFamilyResponse,
|
||||
routes::user::LeaveFamilyResponse,
|
||||
routes::user::FamilyMember,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
@@ -102,7 +106,8 @@ pub use middleware::{require_admin, require_family_access};
|
||||
(name = "categories", description = "Category management endpoints"),
|
||||
(name = "expenses", description = "Expense management endpoints"),
|
||||
(name = "shopping-items", description = "Shopping list management endpoints"),
|
||||
(name = "invite-links", description = "Family invite link management endpoints")
|
||||
(name = "invite-links", description = "Family invite link management endpoints"),
|
||||
(name = "user", description = "User profile management endpoints")
|
||||
),
|
||||
info(
|
||||
title = "Family Budget API",
|
||||
@@ -154,6 +159,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
||||
.route("/login", post(routes::auth::login))
|
||||
.route("/logout", post(routes::auth::logout))
|
||||
.route("/me", get(routes::auth::me))
|
||||
.route("/me/leave-family", post(routes::user::leave_family))
|
||||
.route("/my-family", post(routes::family::create_my_family))
|
||||
.route("/auth/family-login", post(routes::auth::family_login))
|
||||
.layer(auth_layer.clone())
|
||||
@@ -193,6 +199,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
||||
.route("/families/:family_id/shopping-items/:id/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased))
|
||||
.route("/families/:family_id/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased))
|
||||
.route("/families/:family_id/shopping-items/clear-all", delete(routes::shopping_item::clear_all))
|
||||
.route("/families/:family_id/members", get(routes::user::get_family_members))
|
||||
.route_layer(axum_middleware::from_fn(middleware::require_family_access))
|
||||
.layer(session_layer.clone())
|
||||
.with_state(db.clone());
|
||||
|
||||
@@ -5,3 +5,4 @@ pub mod auth;
|
||||
pub mod shopping_item;
|
||||
pub mod oauth;
|
||||
pub mod invite_link;
|
||||
pub mod user;
|
||||
|
||||
97
backend/src/routes/user.rs
Normal file
97
backend/src/routes/user.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use axum::{
|
||||
extract::{State, Path},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use axum_login::AuthSession;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::auth::AuthBackend;
|
||||
use crate::services::{UserService, FamilyService};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LeaveFamilyResponse {
|
||||
pub family_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FamilyMember {
|
||||
pub id: i32,
|
||||
pub username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/me/leave-family",
|
||||
tag = "user",
|
||||
responses(
|
||||
(status = 200, description = "Left family successfully", body = LeaveFamilyResponse),
|
||||
(status = 400, description = "User is not in a family"),
|
||||
(status = 401, description = "Not authenticated")
|
||||
)
|
||||
)]
|
||||
pub async fn leave_family(
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(db): State<DatabaseConnection>,
|
||||
) -> Result<Json<LeaveFamilyResponse>, StatusCode> {
|
||||
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let result = UserService::leave_family(&db, user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("not in a family") {
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(LeaveFamilyResponse {
|
||||
family_deleted: result.family_deleted,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/families/{family_id}/members",
|
||||
tag = "families",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of family members", body = Vec<FamilyMember>),
|
||||
(status = 401, description = "Not authenticated"),
|
||||
(status = 403, description = "Access denied")
|
||||
)
|
||||
)]
|
||||
pub async fn get_family_members(
|
||||
auth_session: AuthSession<AuthBackend>,
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(family_id): Path<i32>,
|
||||
) -> Result<Json<Vec<FamilyMember>>, StatusCode> {
|
||||
let user = auth_session.user.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if user.family_id != Some(family_id) && !user.is_admin {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let members = FamilyService::get_members(&db, family_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let response: Vec<FamilyMember> = members
|
||||
.into_iter()
|
||||
.map(|m| FamilyMember {
|
||||
id: m.id,
|
||||
username: m.username,
|
||||
email: m.email,
|
||||
is_admin: m.is_admin,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use argon2::{
|
||||
Argon2,
|
||||
};
|
||||
use crate::models::family::{self, Entity as Family, Model as FamilyModel};
|
||||
use crate::models::user::{self, Entity as User, Model as UserModel};
|
||||
|
||||
pub struct FamilyService;
|
||||
|
||||
@@ -72,4 +73,11 @@ impl FamilyService {
|
||||
let family: family::ActiveModel = family.into();
|
||||
family.delete(db).await
|
||||
}
|
||||
|
||||
pub async fn get_members(db: &DatabaseConnection, family_id: i32) -> Result<Vec<UserModel>, DbErr> {
|
||||
User::find()
|
||||
.filter(user::Column::FamilyId.eq(family_id))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod expense_service;
|
||||
pub mod shopping_item_service;
|
||||
pub mod oauth_service;
|
||||
pub mod invite_link_service;
|
||||
pub mod user_service;
|
||||
|
||||
pub use family_service::FamilyService;
|
||||
pub use category_service::CategoryService;
|
||||
@@ -11,3 +12,4 @@ pub use expense_service::ExpenseService;
|
||||
pub use shopping_item_service::ShoppingItemService;
|
||||
pub use oauth_service::OAuthService;
|
||||
pub use invite_link_service::InviteLinkService;
|
||||
pub use user_service::UserService;
|
||||
|
||||
44
backend/src/services/user_service.rs
Normal file
44
backend/src/services/user_service.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::*;
|
||||
use crate::models::user::{self, Entity as User, Model as UserModel};
|
||||
use crate::models::family::{Entity as Family};
|
||||
|
||||
pub struct UserService;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LeaveFamilyResult {
|
||||
pub family_deleted: bool,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn leave_family(db: &DatabaseConnection, user_id: i32) -> Result<LeaveFamilyResult, DbErr> {
|
||||
let user = User::find_by_id(user_id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(DbErr::RecordNotFound("User not found".to_string()))?;
|
||||
|
||||
let family_id = user.family_id
|
||||
.ok_or(DbErr::Custom("User is not in a family".to_string()))?;
|
||||
|
||||
let mut user_active: user::ActiveModel = user.into();
|
||||
user_active.family_id = Set(None);
|
||||
user_active.update(db).await?;
|
||||
|
||||
let remaining_members = User::find()
|
||||
.filter(user::Column::FamilyId.eq(family_id))
|
||||
.count(db)
|
||||
.await?;
|
||||
|
||||
let family_deleted = if remaining_members == 0 {
|
||||
Family::delete_by_id(family_id).exec(db).await?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(LeaveFamilyResult { family_deleted })
|
||||
}
|
||||
|
||||
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<UserModel>, DbErr> {
|
||||
User::find_by_id(id).one(db).await
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,25 @@ import FamilyView from './pages/FamilyView';
|
||||
import AdminPanel from './pages/AdminPanel';
|
||||
import NoFamily from './pages/NoFamily';
|
||||
import InvitePage from './pages/InvitePage';
|
||||
import Profile from './pages/Profile';
|
||||
import { useStore } from './store/useStore';
|
||||
import { authApi } from './api/client';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
function AppContent() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user, isAuthenticated, isLoading, setUser, setIsLoading } = useStore();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem('theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', storedTheme);
|
||||
|
||||
const storedLocale = localStorage.getItem('locale');
|
||||
if (storedLocale && storedLocale !== i18n.language) {
|
||||
i18n.changeLanguage(storedLocale);
|
||||
}
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
@@ -61,6 +70,7 @@ function AppContent() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/adminpanel" element={<AdminPanel />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="*" element={<NoFamily />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -70,6 +80,7 @@ function AppContent() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={`/family/${user.family_id}`} replace />} />
|
||||
<Route path="/family/:familyId" element={<FamilyView />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/adminpanel" element={<AdminPanel />} />
|
||||
<Route path="*" element={<Navigate to={`/family/${user.family_id}`} replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
InviteLinkResponse,
|
||||
ValidateInviteResponse,
|
||||
JoinFamilyResponse,
|
||||
FamilyMember,
|
||||
LeaveFamilyResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
@@ -70,6 +72,14 @@ export const familyApi = {
|
||||
|
||||
verifyPassword: (id: number, data: VerifyFamilyPasswordRequest) =>
|
||||
apiClient.post<VerifyFamilyPasswordResponse>(`/families/${id}/verify`, data),
|
||||
|
||||
getMembers: (familyId: number) =>
|
||||
apiClient.get<FamilyMember[]>(`/families/${familyId}/members`),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
leaveFamily: () =>
|
||||
apiClient.post<LeaveFamilyResponse>('/me/leave-family'),
|
||||
};
|
||||
|
||||
export const categoryApi = {
|
||||
|
||||
@@ -139,5 +139,26 @@
|
||||
"markButton": "Mark",
|
||||
"clearAll": "Clear list?",
|
||||
"clearAllMessage": "All items will be permanently removed from the list."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"info": "Information",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"family": "Family",
|
||||
"familyName": "Family name",
|
||||
"members": "Members",
|
||||
"you": "You",
|
||||
"unknownUser": "User",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"dangerZone": "Danger zone",
|
||||
"leaveFamily": "Leave family",
|
||||
"leaveDescription": "If you leave the family, you will lose access to the budget. If you are the last member, the family will be deleted along with all data.",
|
||||
"leaveConfirm": "Are you sure you want to leave the family?",
|
||||
"leaving": "Leaving...",
|
||||
"leaveError": "Error leaving family",
|
||||
"renameError": "Error renaming family"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,5 +139,26 @@
|
||||
"markButton": "Пометить",
|
||||
"clearAll": "Очистить список?",
|
||||
"clearAllMessage": "Все покупки будут удалены из списка безвозвратно."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Личный кабинет",
|
||||
"info": "Информация",
|
||||
"username": "Имя пользователя",
|
||||
"email": "Email",
|
||||
"family": "Семья",
|
||||
"familyName": "Название семьи",
|
||||
"members": "Участники",
|
||||
"you": "Вы",
|
||||
"unknownUser": "Пользователь",
|
||||
"settings": "Настройки",
|
||||
"language": "Язык",
|
||||
"theme": "Тема",
|
||||
"dangerZone": "Опасная зона",
|
||||
"leaveFamily": "Покинуть семью",
|
||||
"leaveDescription": "Если вы покинете семью, вы потеряете доступ к бюджету. Если вы последний участник, семья будет удалена вместе со всеми данными.",
|
||||
"leaveConfirm": "Вы уверены, что хотите покинуть семью?",
|
||||
"leaving": "Выход...",
|
||||
"leaveError": "Ошибка при выходе из семьи",
|
||||
"renameError": "Ошибка при переименовании семьи"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--gradient-start: #667eea;
|
||||
--gradient-end: #764ba2;
|
||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--gradient-start: #1f2937;
|
||||
--gradient-end: #111827;
|
||||
--glass-bg: rgba(31, 41, 55, 0.9);
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
}
|
||||
|
||||
[data-theme="sunset"] {
|
||||
--gradient-start: #f97316;
|
||||
--gradient-end: #ec4899;
|
||||
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
[data-theme="ocean"] {
|
||||
--gradient-start: #3b82f6;
|
||||
--gradient-end: #06b6d4;
|
||||
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
--gradient-start: #22c55e;
|
||||
--gradient-end: #14b8a6;
|
||||
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
[data-theme="purple"] {
|
||||
--gradient-start: #8b5cf6;
|
||||
--gradient-end: #ec4899;
|
||||
--glass-bg: rgba(255, 255, 255, 0.85);
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
}
|
||||
|
||||
.gradient-bg-light {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
UserPlus,
|
||||
Copy,
|
||||
Check,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import ShoppingListModal from '../components/ShoppingListModal';
|
||||
|
||||
@@ -267,13 +268,22 @@ export default function FamilyView() {
|
||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<button
|
||||
onClick={handleOpenInviteModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300 group"
|
||||
>
|
||||
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">{t('family.inviteMember')}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
onClick={handleOpenInviteModal}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
||||
>
|
||||
<UserPlus className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">{t('family.inviteMember')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/profile')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md transition-all duration-300 group"
|
||||
>
|
||||
<User className="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-medium">{t('profile.title')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
||||
<Wallet className="w-12 h-12 text-white" />
|
||||
|
||||
354
frontend/src/pages/Profile.tsx
Normal file
354
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { familyApi, userApi, authApi } from '../api/client';
|
||||
import { useStore } from '../store/useStore';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Users,
|
||||
Settings,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Check,
|
||||
Palette,
|
||||
Languages,
|
||||
LogOut,
|
||||
Edit3,
|
||||
Save,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const THEMES: { id: Theme; name: string; gradient: string }[] = [
|
||||
{ id: 'light', name: 'Light', gradient: 'bg-gradient-to-r from-gray-100 to-gray-200' },
|
||||
{ id: 'dark', name: 'Dark', gradient: 'bg-gradient-to-r from-gray-800 to-gray-900' },
|
||||
{ id: 'sunset', name: 'Sunset', gradient: 'bg-gradient-to-r from-orange-400 to-pink-500' },
|
||||
{ id: 'ocean', name: 'Ocean', gradient: 'bg-gradient-to-r from-blue-400 to-cyan-500' },
|
||||
{ id: 'forest', name: 'Forest', gradient: 'bg-gradient-to-r from-green-400 to-teal-500' },
|
||||
{ id: 'purple', name: 'Purple', gradient: 'bg-gradient-to-r from-purple-500 to-pink-500' },
|
||||
];
|
||||
|
||||
export default function Profile() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user, selectedFamily, setSelectedFamily, setUser, preferences, setPreferences, familyMembers, setFamilyMembers } = useStore();
|
||||
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const [leavingFamily, setLeavingFamily] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newFamilyName, setNewFamilyName] = useState('');
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.family_id && selectedFamily) {
|
||||
loadMembers();
|
||||
}
|
||||
}, [user?.family_id, selectedFamily]);
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!user?.family_id) return;
|
||||
try {
|
||||
setMembersLoading(true);
|
||||
const response = await familyApi.getMembers(user.family_id);
|
||||
setFamilyMembers(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error loading members:', err);
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveFamily = async () => {
|
||||
if (!confirm(t('profile.leaveConfirm'))) return;
|
||||
|
||||
try {
|
||||
setLeavingFamily(true);
|
||||
await userApi.leaveFamily();
|
||||
|
||||
const meResponse = await authApi.me();
|
||||
setUser(meResponse.data);
|
||||
setSelectedFamily(null);
|
||||
setFamilyMembers([]);
|
||||
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
console.error('Error leaving family:', err);
|
||||
alert(t('profile.leaveError'));
|
||||
} finally {
|
||||
setLeavingFamily(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: Theme) => {
|
||||
setPreferences({ theme });
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
};
|
||||
|
||||
const handleLocaleChange = (locale: 'ru' | 'en') => {
|
||||
setPreferences({ locale });
|
||||
i18n.changeLanguage(locale);
|
||||
};
|
||||
|
||||
const handleStartEditName = () => {
|
||||
setNewFamilyName(selectedFamily?.name || '');
|
||||
setEditingName(true);
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!selectedFamily || !newFamilyName.trim()) return;
|
||||
|
||||
try {
|
||||
setSavingName(true);
|
||||
const response = await familyApi.update(selectedFamily.id, { name: newFamilyName.trim() });
|
||||
setSelectedFamily(response.data);
|
||||
setEditingName(false);
|
||||
} catch (err) {
|
||||
console.error('Error updating family name:', err);
|
||||
alert(t('profile.renameError'));
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (user?.family_id) {
|
||||
navigate(`/family/${user.family_id}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen gradient-bg py-8 sm:py-12 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="font-medium">{t('common.back')}</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex p-4 bg-white/20 backdrop-blur-md rounded-2xl mb-4">
|
||||
<UserIcon className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">{t('profile.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-500 text-white rounded-xl">
|
||||
<UserIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.info')}</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||
<span className="text-gray-600">{t('profile.username')}</span>
|
||||
<span className="font-medium text-gray-900">{user?.username || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||
<span className="text-gray-600">{t('profile.email')}</span>
|
||||
<span className="font-medium text-gray-900">{user?.email || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFamily && (
|
||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-green-500 to-teal-500 text-white rounded-xl">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.family')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center py-2 border-b border-gray-200">
|
||||
<span className="text-gray-600">{t('profile.familyName')}</span>
|
||||
{editingName ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newFamilyName}
|
||||
onChange={(e) => setNewFamilyName(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={savingName}
|
||||
className="p-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{savingName ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingName(false)}
|
||||
className="p-1.5 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{selectedFamily.name}</span>
|
||||
<button
|
||||
onClick={handleStartEditName}
|
||||
className="p-1.5 text-gray-500 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-3">{t('profile.members')}</h3>
|
||||
{membersLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{familyMembers.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`flex items-center justify-between p-3 rounded-xl ${member.id === user?.id ? 'bg-purple-50 border border-purple-200' : 'bg-gray-50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-purple-400 to-blue-400 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{(member.username || member.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-gray-800">
|
||||
{member.username || member.email || t('profile.unknownUser')}
|
||||
</span>
|
||||
{member.id === user?.id && (
|
||||
<span className="text-xs bg-purple-200 text-purple-700 px-2 py-0.5 rounded-full">
|
||||
{t('profile.you')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{member.is_admin && (
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass-effect rounded-2xl shadow-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-purple-500 to-pink-500 text-white rounded-xl">
|
||||
<Settings className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.settings')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Languages className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-medium text-gray-600">{t('profile.language')}</h3>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleLocaleChange('ru')}
|
||||
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
||||
preferences.locale === 'ru'
|
||||
? 'bg-purple-500 text-white shadow-lg'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">🇷🇺</span>
|
||||
Русский
|
||||
{preferences.locale === 'ru' && <Check className="w-4 h-4 inline ml-2" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLocaleChange('en')}
|
||||
className={`flex-1 py-3 px-4 rounded-xl font-medium transition-all ${
|
||||
preferences.locale === 'en'
|
||||
? 'bg-purple-500 text-white shadow-lg'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">🇬🇧</span>
|
||||
English
|
||||
{preferences.locale === 'en' && <Check className="w-4 h-4 inline ml-2" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Palette className="w-4 h-4 text-gray-600" />
|
||||
<h3 className="text-sm font-medium text-gray-600">{t('profile.theme')}</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => handleThemeChange(theme.id)}
|
||||
className={`relative p-1 rounded-xl transition-all ${
|
||||
preferences.theme === theme.id
|
||||
? 'ring-2 ring-purple-500 ring-offset-2'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div className={`h-12 rounded-lg ${theme.gradient}`} />
|
||||
<span className="text-xs text-gray-600 mt-1 block">{theme.name}</span>
|
||||
{preferences.theme === theme.id && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow">
|
||||
<Check className="w-3 h-3 text-purple-600" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFamily && (
|
||||
<div className="glass-effect rounded-2xl shadow-lg p-6 border-2 border-red-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-gradient-to-br from-red-500 to-orange-500 text-white rounded-xl">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-800">{t('profile.dangerZone')}</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-4">{t('profile.leaveDescription')}</p>
|
||||
|
||||
<button
|
||||
onClick={handleLeaveFamily}
|
||||
disabled={leavingFamily}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold disabled:opacity-50"
|
||||
>
|
||||
{leavingFamily ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{t('profile.leaving')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{t('profile.leaveFamily')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Family, Category, User } from '../types';
|
||||
import type { Family, Category, User, FamilyMember, Theme, Locale } from '../types';
|
||||
|
||||
const getStoredPreferences = () => {
|
||||
const theme = (localStorage.getItem('theme') as Theme) || 'light';
|
||||
const locale = (localStorage.getItem('locale') as Locale) || 'ru';
|
||||
return { theme, locale };
|
||||
};
|
||||
|
||||
interface AppState {
|
||||
user: User | null;
|
||||
@@ -8,12 +14,16 @@ interface AppState {
|
||||
selectedFamily: Family | null;
|
||||
families: Family[];
|
||||
categories: Category[];
|
||||
familyMembers: FamilyMember[];
|
||||
preferences: { theme: Theme; locale: Locale };
|
||||
|
||||
setUser: (user: User | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setSelectedFamily: (family: Family | null) => void;
|
||||
setFamilies: (families: Family[]) => void;
|
||||
setCategories: (categories: Category[]) => void;
|
||||
setFamilyMembers: (members: FamilyMember[]) => void;
|
||||
setPreferences: (prefs: Partial<{ theme: Theme; locale: Locale }>) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -24,6 +34,8 @@ export const useStore = create<AppState>((set) => ({
|
||||
selectedFamily: null,
|
||||
families: [],
|
||||
categories: [],
|
||||
familyMembers: [],
|
||||
preferences: getStoredPreferences(),
|
||||
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
|
||||
@@ -35,11 +47,21 @@ export const useStore = create<AppState>((set) => ({
|
||||
|
||||
setCategories: (categories) => set({ categories }),
|
||||
|
||||
setFamilyMembers: (familyMembers) => set({ familyMembers }),
|
||||
|
||||
setPreferences: (prefs) => set((state) => {
|
||||
const newPrefs = { ...state.preferences, ...prefs };
|
||||
if (prefs.theme) localStorage.setItem('theme', prefs.theme);
|
||||
if (prefs.locale) localStorage.setItem('locale', prefs.locale);
|
||||
return { preferences: newPrefs };
|
||||
}),
|
||||
|
||||
logout: () => set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
selectedFamily: null,
|
||||
families: [],
|
||||
categories: []
|
||||
categories: [],
|
||||
familyMembers: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -130,3 +130,22 @@ export interface JoinFamilyResponse {
|
||||
family_id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'sunset' | 'ocean' | 'forest' | 'purple';
|
||||
export type Locale = 'ru' | 'en';
|
||||
|
||||
export interface FamilyMember {
|
||||
id: number;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export interface LeaveFamilyResponse {
|
||||
family_deleted: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user