2 Commits

Author SHA1 Message Date
2dfab403c8 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
Reviewed-on: http://192.168.31.100:3847/Arrelin/family_budget/pulls/2
2026-01-23 09:52:11 +00:00
arrelin
b18f69ea62 personal account 2026-01-23 12:51:34 +03:00
15 changed files with 688 additions and 12 deletions

View File

@@ -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());

View File

@@ -5,3 +5,4 @@ pub mod auth;
pub mod shopping_item;
pub mod oauth;
pub mod invite_link;
pub mod user;

View 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))
}

View File

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

View File

@@ -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;

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

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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"
}
}

View File

@@ -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": "Ошибка при переименовании семьи"
}
}

View File

@@ -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 {

View File

@@ -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" />

View 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>
);
}

View File

@@ -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: [],
}),
}));

View File

@@ -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;
}