Compare commits

..

1 Commits

Author SHA1 Message Date
IluaAir
7e9346ec4d add base update one 2025-09-06 13:53:18 +03:00
75 changed files with 197 additions and 9491 deletions

View File

@@ -50,22 +50,6 @@ ___
### Frontend ### Frontend
- **React.js** - **React.js**
- **Vite** - **Vite**
- **Tailwind CSS**
- **Material Design 3**
- **Custom Design System** (Mirage Palette + Neon)
---
## 🎨 Design System
Проект использует собственную систему дизайна:
- **Mirage Color Palette** - профессиональная темная сине-синяя тема
- **Material Design 3** - современные UI паттерны
- **Neon Cyberpunk** - уникальные неоновые акценты
**Документация:**
- [Frontend Guide](taskncoffee-app/README.md) - документация frontend
- [Styles Cheatsheet](taskncoffee-app/docs/STYLES_CHEATSHEET.md) - быстрая справка
--- ---

View File

@@ -5,7 +5,9 @@ from fastapi import Depends, Query
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from src.schemas.tasks import PriorityEnum, StatusEnum from src.schemas.tasks import TaskFilter
TaskFilterDep = Annotated[TaskFilter, Depends()]
class Date(BaseModel): class Date(BaseModel):
@@ -21,21 +23,4 @@ class Date(BaseModel):
return self return self
class Page(BaseModel): DateDep = Annotated[Date, Depends()]
limit: int = Query(default=30, ge=1, le=100)
page: int | None = Query(default=1, ge=1)
class Status(BaseModel):
status: StatusEnum | None = Query(default=None)
class Priority(BaseModel):
priority: PriorityEnum | None = Query(default=None)
class Filters(Date, Status, Priority, Page):
pass
FilterDep = Annotated[Filters, Depends()]

View File

@@ -1,63 +1,86 @@
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException, Path
from fastapi.security import ( from fastapi.security import OAuth2PasswordBearer
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jwt import InvalidTokenError from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager from src.core.auth_manager import AuthManager
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import TokenData from src.schemas.auth import TokenData
from src.services.tasks import TaskService
http_bearer = HTTPBearer(auto_error=False) from src.services.users import UserService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
AccessTokenDep = Annotated[HTTPAuthorizationCredentials, Depends(http_bearer)]
async def get_current_user( async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
token: AccessTokenDep, verify_exp: bool = True, check_active: bool = False
):
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=401, status_code=401,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = AuthManager.decode_access_token(token.credentials, verify_exp) payload = AuthManager.decode_access_token(token=token)
if payload is None: if payload is None:
raise credentials_exception raise credentials_exception
user = TokenData(**payload) user = TokenData(**payload)
if check_active and not user.is_active: except InvalidTokenError:
raise HTTPException(status_code=400, detail="Inactive user")
except (InvalidTokenError, AttributeError):
raise credentials_exception raise credentials_exception
return user return user
async def get_current_user_basic(token: AccessTokenDep): CurrentUser = Annotated[TokenData, Depends(get_current_user)]
return await get_current_user(token, verify_exp=True, check_active=False)
async def get_current_active_user(token: AccessTokenDep): def get_current_active_user(
return await get_current_user(token, verify_exp=True, check_active=True) current_user: CurrentUser,
):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def get_current_user_for_refresh(token: AccessTokenDep): ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
return await get_current_user(token, verify_exp=False, check_active=True)
async def get_current_user_for_admin(token: AccessTokenDep): async def get_admin_user(db: sessionDep, current_user: ActiveUser):
admin = await get_current_user(token, verify_exp=True, check_active=True) await UserService(db).validate_admin_user(current_user.sub)
if not admin.is_superuser: return current_user
raise HTTPException(status_code=403, detail="Admin access required")
AdminUser = Annotated[TokenData, Depends(get_admin_user)]
async def user_or_admin(db: sessionDep, current_user: ActiveUser, owner_id: int):
if current_user.id == owner_id:
return current_user
else:
admin = await get_admin_user(db, current_user)
return admin return admin
CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)] async def CurrentOrAdminOwner(
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)] db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)] ):
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)] authorized_user = await user_or_admin(db, current_user, id)
if not authorized_user:
raise HTTPException(status_code=403, detail="Not authorized")
return authorized_user
async def CurrentOrAdminTask(
db: sessionDep,
id: Annotated[int, Path()],
current_user: ActiveUser,
):
task = await TaskService(db).get_task(id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return await CurrentOrAdminOwner(db, current_user, task.user_id)
OwnerDep = Annotated[TokenData, Depends(CurrentOrAdminOwner)]
TaskOwnerDep = Annotated[TokenData, Depends(CurrentOrAdminTask)]

View File

@@ -1,19 +1,14 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Cookie, Depends, Form, HTTPException, Response from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser, RefreshUser, http_bearer
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import Token
from src.schemas.users import UserRequestADD from src.schemas.users import UserRequestADD
from src.services.auth import AuthService from src.services.auth import AuthService
from src.services.users import UserService
router = APIRouter( router = APIRouter(prefix=settings.api.v1.auth, tags=["Auth"])
prefix=settings.api.v1.auth, tags=["Auth"], dependencies=[Depends(http_bearer)]
)
@router.post(path="/signup") @router.post(path="/signup")
@@ -22,65 +17,12 @@ async def registration(session: sessionDep, credential: UserRequestADD):
return auth return auth
@router.post(path="/login", response_model=Token) @router.post(path="/login")
async def login( async def login(
session: sessionDep, session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()], credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
fingerprint: str = Form(min_length=5),
): ):
result = await AuthService(session).login( access_token = await AuthService(session).login(
credential.username, credential.password, fingerprint=fingerprint credential.username, credential.password
) )
response.set_cookie( return access_token
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.post(path="/refresh", response_model=Token)
async def refresh(
session: sessionDep,
current_user: RefreshUser,
response: Response,
fingerprint: Annotated[str, Body(embed=True)],
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
result = await AuthService(session).refresh_tokens(
refresh_token, current_user, fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@router.get("/me")
async def get_me(session: sessionDep, user: ActiveUser):
cur_user = await UserService(session).get_user_by_filter_or_raise(id=user.id)
return cur_user
@router.post(path="/logout")
async def logout(
session: sessionDep,
response: Response,
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
):
if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token")
await AuthService(session).delete_token(token=refresh_token)
response.delete_cookie(key="refresh_token")
return {"status": "ok"}

View File

@@ -1,20 +1,28 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Body, Depends, HTTPException from fastapi import APIRouter, Body, Depends
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.user_dep import ActiveUser from src.api.dependacies.task_dep import TaskFilterDep
from src.api.dependacies.user_dep import ActiveUser, TaskOwnerDep
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService from src.services.tasks import TaskService
from src.services.users import UserService
router = APIRouter(prefix="/tasks", tags=["Tasks"]) router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
async def get_tasks(session: sessionDep, user: ActiveUser, filter: TaskFilterDep):
result = await UserService(session).get_user_with_tasks(
user_id=user.id, **filter.model_dump(exclude_unset=True)
)
return result
@router.get("/{id}") @router.get("/{id}")
async def get_task_id(session: sessionDep, id: int, user: ActiveUser): async def get_task_id(session: sessionDep, id: int, _: TaskOwnerDep):
task = await TaskService(session).get_task(id) task = await TaskService(session).get_task(id)
if task.user_id != user.id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
return task return task
@@ -34,26 +42,17 @@ async def post_task(
async def patch_task( async def patch_task(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: TaskOwnerDep,
task_data: TaskPATCHRequest = Body(), task_data: TaskPATCHRequest = Body(),
): ):
if user.is_superuser is False: task = await TaskService(session).update_task(id, task_data)
task = await TaskService(session).get_task(id) return task
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
updated_task = await TaskService(session).update_task(id, task_data)
return updated_task
@router.delete("/{id}") @router.delete("/{id}")
async def delete_task( async def delete_task(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: TaskOwnerDep,
): ):
if user.is_superuser is False:
task = await TaskService(session).get_task(id)
if task.user_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await TaskService(session).delete_task(id) await TaskService(session).delete_task(id)
return {"message": "Task deleted successfully"}

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import ( from src.api.dependacies.user_dep import (
ActiveUser, ActiveUser,
AdminUser, AdminUser,
OwnerDep,
) )
from src.core.settings import settings from src.core.settings import settings
from src.schemas.users import UserUpdate from src.schemas.users import UserUpdate
@@ -13,6 +13,11 @@ from src.services.users import UserService
router = APIRouter(prefix=settings.api.v1.users, tags=["Users"]) router = APIRouter(prefix=settings.api.v1.users, tags=["Users"])
@router.get("/me")
async def get_me(user: ActiveUser):
return user
@router.get("/") @router.get("/")
async def get_all_users(session: sessionDep, _: AdminUser): async def get_all_users(session: sessionDep, _: AdminUser):
users = await UserService(session).get_all_users() users = await UserService(session).get_all_users()
@@ -20,30 +25,18 @@ async def get_all_users(session: sessionDep, _: AdminUser):
@router.get("/{id}") @router.get("/{id}")
async def get_user_by_id(session: sessionDep, id: int, _: AdminUser): async def get_user_by_id(session: sessionDep, id: int, _: OwnerDep):
user = await UserService(session).get_user_by_filter_or_raise(id=id) user = await UserService(session).get_user_by_filter_or_raise(id=id)
return user return user
@router.get("/{id}/tasks")
async def get_user_tasks(
session: sessionDep, id: int, user: ActiveUser, filters: FilterDep
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
tasks = await UserService(session).get_user_with_tasks(id, **filters.model_dump())
return tasks.tasks
@router.patch("/{id}") @router.patch("/{id}")
async def patch_user( async def patch_user(
session: sessionDep, session: sessionDep,
id: int, id: int,
user: ActiveUser, _: OwnerDep,
user_update: UserUpdate = Body(), user_update: UserUpdate = Body(),
): ):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
updated_user = await UserService(session).update_user( updated_user = await UserService(session).update_user(
id=id, update_data=user_update id=id, update_data=user_update
) )
@@ -51,6 +44,6 @@ async def patch_user(
@router.delete("/{id}") @router.delete("/{id}")
async def delete_user(session: sessionDep, id: int, user: AdminUser): async def delete_user(session: sessionDep, id: int, _: AdminUser):
await UserService(session).delete_user(id) await UserService(session).delete_user(id)
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}

View File

@@ -1,4 +1,3 @@
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import jwt import jwt
@@ -36,15 +35,9 @@ class AuthManager:
return encoded_jwt return encoded_jwt
@classmethod @classmethod
def create_refresh_token(cls) -> str: def decode_access_token(cls, token: str) -> dict:
token_hash = secrets.token_urlsafe(32)
return token_hash
@classmethod
def decode_access_token(cls, token: str, verify_exp: bool = True) -> dict:
return jwt.decode( return jwt.decode(
token, token,
settings.access_token.secret_key, settings.access_token.secret_key,
algorithms=[settings.access_token.algorithm], algorithms=[settings.access_token.algorithm],
options={"verify_exp": verify_exp},
) )

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from src.core.settings import settings from src.core.settings import settings
engine = create_async_engine(settings.db.url, echo=settings.db.echo) engine = create_async_engine(settings.db.url, echo=True)
@event.listens_for(engine.sync_engine, "connect") @event.listens_for(engine.sync_engine, "connect")

View File

@@ -2,7 +2,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
@@ -15,7 +14,6 @@ class DBManager:
self.session: AsyncSession = self.session_factory() self.session: AsyncSession = self.session_factory()
self.user = UsersRepo(self.session) self.user = UsersRepo(self.session)
self.task = TasksRepo(self.session) self.task = TasksRepo(self.session)
self.auth = AuthRepo(self.session)
return self return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:

View File

@@ -3,16 +3,18 @@ from typing import TYPE_CHECKING, Any, Protocol
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING: if TYPE_CHECKING:
from src.repository.auth import AuthRepo
from src.repository.tasks import TasksRepo from src.repository.tasks import TasksRepo
from src.repository.users import UsersRepo from src.repository.users import UsersRepo
class HasId(Protocol):
id: Any
class IUOWDB(Protocol): class IUOWDB(Protocol):
session: AsyncSession session: AsyncSession
user: "UsersRepo" user: 'UsersRepo'
task: "TasksRepo" task: 'TasksRepo'
auth: "AuthRepo"
async def __aenter__(self) -> "IUOWDB": ... async def __aenter__(self) -> "IUOWDB": ...

View File

@@ -28,34 +28,22 @@ class ApiPrefix(BaseModel):
class DbSettings(BaseModel): class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}" url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseModel): class AccessToken(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_prefix="ACCESS_TOKEN_"
)
expire_minutes: int expire_minutes: int
secret_key: str secret_key: str
algorithm: str = "HS256" algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105 token_type: str = "bearer" # noqa: S105
class RefreshToken(BaseModel):
expire_days: int
class CorsSettings(BaseModel):
production: str
local: list[str] = ["http://localhost:5173", "http://localhost:5174"]
class Settings(BaseSettings): class Settings(BaseSettings):
api: ApiPrefix = ApiPrefix() api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings() db: DbSettings = DbSettings()
access_token: AccessToken access_token: AccessToken = AccessToken() # type: ignore
refresh_token: RefreshToken
cors_settings: CorsSettings
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__"
)
settings = Settings() # type: ignore settings = Settings()

View File

@@ -5,21 +5,10 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
sys.path.append(str(Path(__file__).parent.parent)) sys.path.append(str(Path(__file__).parent.parent))
from fastapi.middleware.cors import CORSMiddleware
from src.api import router from src.api import router
from src.core.settings import settings
app = FastAPI(title="Task&Coffee") app = FastAPI(title="Task&Coffee")
app.include_router(router=router) app.include_router(router=router)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_settings.local + [settings.cors_settings.production],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("src.main:app", port=8000, log_level="info", reload=True) uvicorn.run("src.main:app", port=8000, log_level="info", reload=True)

View File

@@ -1,48 +0,0 @@
"""add refresh token
Revision ID: b879d3502c37
Revises: 4b0f3ea2fd26
Create Date: 2025-09-08 14:56:01.439089
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b879d3502c37"
down_revision: Union[str, None] = "4b0f3ea2fd26"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("refresh_tokens")
# ### end Alembic commands ###

View File

@@ -1,45 +0,0 @@
"""expire and fingerprintjs token
Revision ID: 5821f37941a8
Revises: b879d3502c37
Create Date: 2025-09-21 20:16:48.289050
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5821f37941a8"
down_revision: Union[str, None] = "b879d3502c37"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"refresh_tokens",
sa.Column("fingerprint", sa.String(length=255), nullable=False),
)
op.add_column(
"refresh_tokens",
sa.Column(
"expired_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.text("(datetime('now', '+7 days'))"),
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("refresh_tokens", "expired_at")
op.drop_column("refresh_tokens", "fingerprint")
# ### end Alembic commands ###

View File

@@ -1,5 +1,7 @@
from src.models.tasks import TasksORM from src.models.tasks import TasksORM
from src.models.tokens import RefreshTokensORM
from src.models.users import UsersORM from src.models.users import UsersORM
__all__ = ["UsersORM", "TasksORM", "RefreshTokensORM"] __all__ = [
"UsersORM",
"TasksORM",
]

View File

@@ -1,21 +0,0 @@
from datetime import datetime
from sqlalchemy import TIMESTAMP, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from src.core.database import Base
from src.core.settings import settings
class RefreshTokensORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
token: Mapped[str] = mapped_column(String(255), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
expired_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True),
server_default=text(
f"datetime('now', '+{settings.refresh_token.expire_days} days')"
),
)

View File

@@ -1,6 +0,0 @@
from src.models.tokens import RefreshTokensORM
from src.repository.base import BaseRepo
class AuthRepo(BaseRepo[RefreshTokensORM]):
model: type[RefreshTokensORM] = RefreshTokensORM

View File

@@ -3,7 +3,9 @@ from typing import Any, Generic, Mapping, Sequence, Type, TypeVar
from sqlalchemy import delete, insert, select, update from sqlalchemy import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
ModelType = TypeVar("ModelType") from src.core.interfaces import HasId
ModelType = TypeVar("ModelType", bound=HasId)
class BaseRepo(Generic[ModelType]): class BaseRepo(Generic[ModelType]):
@@ -43,9 +45,12 @@ class BaseRepo(Generic[ModelType]):
async def delete_one(self, **filter_by) -> None: async def delete_one(self, **filter_by) -> None:
await self.session.execute(delete(self.model).filter_by(**filter_by)) await self.session.execute(delete(self.model).filter_by(**filter_by))
async def update_one(self, data: dict[str, Any], **filter_by: Any) -> ModelType: async def update_one(self, id: int, data: Mapping[str, Any]) -> ModelType:
stmt = ( stmt = (
update(self.model).filter_by(**filter_by).values(data).returning(self.model) update(self.model)
.where(self.model.id == id)
.values(data)
.returning(self.model)
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
model = result.scalar_one() model = result.scalar_one()

View File

@@ -2,5 +2,5 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo from src.repository.base import BaseRepo
class TasksRepo(BaseRepo[TasksORM]): class TasksRepo(BaseRepo):
model: type[TasksORM] = TasksORM model: type[TasksORM] = TasksORM

View File

@@ -1,6 +1,7 @@
from datetime import date from datetime import date
from typing import Optional
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.models import UsersORM from src.models import UsersORM
@@ -8,24 +9,32 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo from src.repository.base import BaseRepo
class UsersRepo(BaseRepo[UsersORM]): class UsersRepo(BaseRepo):
model: type[UsersORM] = UsersORM model: type[UsersORM] = UsersORM
async def update_one(self, id: int, data: dict) -> UsersORM:
stmt = (
update(self.model)
.where(self.model.id == id)
.values(data)
.returning(self.model)
)
result = await self.session.execute(stmt)
model = result.scalar_one()
return model
async def get_one_with_load( async def get_one_with_load(
self, self,
user_id: int, user_id: int,
status: str | None = None, status: str | None = None,
priority: str | None = None, tasks_limit: Optional[int] = None,
tasks_limit: int | None = None, tasks_offset: Optional[int] = 0,
tasks_offset: int | None = 0, date_to: Optional[date] = None,
date_to: date | None = None, date_from: Optional[date] = None,
date_from: date | None = None,
) -> UsersORM | None: ) -> UsersORM | None:
filters_sq: dict = {"user_id": user_id} filters_sq: dict = {"user_id": user_id}
if status: if status:
filters_sq["status"] = status filters_sq["status"] = status
if priority:
filters_sq["priority"] = priority
tasks_subquery = self._tasks_subquary( tasks_subquery = self._tasks_subquary(
date_from=date_from, date_to=date_to, **filters_sq date_from=date_from, date_to=date_to, **filters_sq
) )

View File

@@ -1,16 +1,12 @@
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
model_config = ConfigDict(extra="ignore")
class TokenData(BaseModel): class TokenData(BaseModel):
id: int id: int
sub: str = Field(alias="username") sub: str
is_superuser: bool
is_active: bool is_active: bool
model_config = ConfigDict(populate_by_name=True)

View File

@@ -50,3 +50,11 @@ class Task(TaskADDRequest):
time_spent: int time_spent: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TaskFilter(BaseModel):
status: StatusEnum | None = None
limit: int | None = 30
offset: int | None = None
date_from: date | None = None
date_to: date | None = None

View File

@@ -3,7 +3,7 @@ from typing import Annotated
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr
from src.schemas.tasks import TaskWithId from src.schemas.tasks import TaskWithId
from src.schemas.validators import ensure_password, ensure_username from src.schemas.validators import ensure_password
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
@@ -19,8 +19,6 @@ class User(BaseModel):
username: str username: str
is_active: bool is_active: bool
is_superuser: bool is_superuser: bool
telegram_id: int | None
avatar_path: str | None
model_config = ConfigDict(from_attributes=True, extra="ignore") model_config = ConfigDict(from_attributes=True, extra="ignore")
@@ -38,7 +36,7 @@ class UserRequest(BaseModel):
class UserRequestADD(BaseModel): class UserRequestADD(BaseModel):
username: Annotated[str, BeforeValidator(ensure_username)] username: str
email: EmailStr | None = None email: EmailStr | None = None
password: Annotated[str, BeforeValidator(ensure_password)] password: Annotated[str, BeforeValidator(ensure_password)]

View File

@@ -1,21 +1,11 @@
from typing import Any from typing import Any
def ensure_password(value: Any) -> str: def ensure_password(value: Any) -> Any:
if not isinstance(value, str): if not isinstance(value, str):
raise TypeError("Password must be a string") raise TypeError("Password must be a string")
value = value.strip()
if len(value) < 8: if len(value) < 8:
raise ValueError("Password must be at least 8 characters") raise ValueError("Password must be at least 8 characters")
elif value.strip() == "": if value.strip() == "":
raise ValueError("Password cannot be empty") raise ValueError("Password cannot be empty")
return value return value
def ensure_username(value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("Username must be at least 3 characters")
elif value.lower() in ["admin", "moderator", "админ", "модератор"]:
raise ValueError("Login is already taken")
return value

View File

@@ -2,7 +2,7 @@ from fastapi import HTTPException
from src.core.auth_manager import AuthManager from src.core.auth_manager import AuthManager
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import TokenData from src.schemas.auth import Token
from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass from src.schemas.users import User, UserAdd, UserRequestADD, UserWithHashedPass
from src.services.base import BaseService from src.services.base import BaseService
@@ -19,50 +19,25 @@ class AuthService(BaseService):
await self.session.commit() await self.session.commit()
return User.model_validate(result) return User.model_validate(result)
async def _tokens_create(self, user_data: TokenData, fingerprint: str): async def login(self, username: str, password: str):
access_token = AuthManager.create_access_token(user_data.model_dump()) result = await self.session.user.get_one_or_none(username=username)
refresh_token = AuthManager.create_refresh_token() if result is None:
await self.session.auth.create_one({ raise HTTPException(
"token": refresh_token,
"user_id": user_data.id,
"fingerprint": fingerprint,
})
return {
"access_token": access_token,
"token_type": settings.access_token.token_type,
"refresh_token": refresh_token,
}
async def login(self, username: str, password: str, fingerprint: str) -> dict:
login_exception = HTTPException(
status_code=401, status_code=401,
detail="Incorrect username or password", detail="Incorrect username or password",
) )
result = await self.session.user.get_one_or_none(username=username)
if result is None:
raise login_exception
user = UserWithHashedPass.model_validate(result) user = UserWithHashedPass.model_validate(result)
token_data = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password( verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password plain_password=password, hashed_password=user.hashed_password
) )
if not verify or user.is_active is False: if not verify or user.is_active is False:
raise login_exception raise HTTPException(
tokens = await self._tokens_create(token_data, fingerprint) status_code=401,
await self.session.commit() detail="Incorrect username or password",
return tokens )
access_token = AuthManager.create_access_token(
async def refresh_tokens( data={"id": user.id, "sub": user.username, "is_active": user.is_active}
self, refresh_token: str, user_data: TokenData, fingerprint: str )
) -> dict: return Token(
token_record = await self.session.auth.get_one_or_none(token=refresh_token) access_token=access_token, token_type=settings.access_token.token_type
if not token_record or token_record.user_id != user_data.id: )
raise HTTPException(status_code=401, detail="Invalid refresh token")
token = await self._tokens_create(user_data, fingerprint)
await self.session.auth.delete_one(token=refresh_token)
await self.session.commit()
return token
async def delete_token(self, token: str) -> None:
await self.session.auth.delete_one(token=token)
await self.session.commit()

View File

@@ -1,10 +1,13 @@
from fastapi import HTTPException from fastapi import HTTPException
from src.models.tasks import TasksORM
from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest
from src.services.base import BaseService from src.services.base import BaseService
class TaskService(BaseService): class TaskService(BaseService):
model = TasksORM
async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task: async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task:
user = await self.session.user.get_one_or_none(id=user_id) user = await self.session.user.get_one_or_none(id=user_id)
if user is None: if user is None:
@@ -26,13 +29,10 @@ class TaskService(BaseService):
await self.session.commit() await self.session.commit()
async def update_task( async def update_task(
self, self, task_id: int, task_data: TaskPATCHRequest, exclude_unset: bool = True
task_id: int,
task_data: TaskPATCHRequest,
exclude_unset: bool = True,
): ):
task = await self.session.task.update_one( task = await self.session.task.update_one(
data=task_data.model_dump(exclude_unset=exclude_unset), id=task_id id=task_id, data=task_data.model_dump(exclude_unset=exclude_unset)
) )
await self.session.commit() await self.session.commit()
return Task.model_validate(task) return Task.model_validate(task)

View File

@@ -1,3 +1,5 @@
from datetime import date
from fastapi import HTTPException from fastapi import HTTPException
from src.schemas.users import User, UserUpdate, UserWithTasks from src.schemas.users import User, UserUpdate, UserWithTasks
@@ -34,24 +36,27 @@ class UserService(BaseService):
async def update_user(self, id: int, update_data: UserUpdate) -> User: async def update_user(self, id: int, update_data: UserUpdate) -> User:
await self.get_user_by_filter_or_raise(id=id) await self.get_user_by_filter_or_raise(id=id)
user = await self.session.user.update_one( user = await self.session.user.update_one(
data=update_data.model_dump(exclude_unset=True), id=id id=id, data=update_data.model_dump(exclude_unset=True)
) )
await self.session.commit() await self.session.commit()
return User.model_validate(user) return User.model_validate(user)
async def get_user_with_tasks(self, user_id: int, **attrs): async def get_user_with_tasks(
if attrs.get("page") and attrs.get("limit"): self,
tasks_offset = (attrs.get("page", 0) - 1) * attrs.get("limit") user_id: int,
else: status: str | None,
tasks_offset = None limit: int | None,
offset: int | None,
date_to: date | None,
date_from: date | None,
):
user = await self.session.user.get_one_with_load( user = await self.session.user.get_one_with_load(
user_id=user_id, user_id=user_id,
status=attrs.get("status"), status=status,
priority=attrs.get("priority"), tasks_limit=limit,
tasks_limit=attrs.get("limit"), tasks_offset=offset,
tasks_offset=tasks_offset, date_from=date_from,
date_from=attrs.get("date_from"), date_to=date_to,
date_to=attrs.get("date_to"),
) )
if user is None: if user is None:
raise HTTPException(status_code=404, detail="User not found.") raise HTTPException(status_code=404, detail="User not found.")

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,76 +0,0 @@
# Task&Coffee Frontend
> Modern task management app with Material Design 3 + Neon aesthetic
## 🚀 Quick Start
```bash
npm install
npm run dev
```
---
## 🎨 Styling System
We use a custom design system combining:
- **Mirage Color Palette** - Professional dark blue theme
- **Material Design 3** - Modern UI patterns
- **Neon Cyberpunk** - Unique glowing accents
### Usage
```jsx
import styles from '@/lib/styles';
<div className={styles.card.filled}>
<h2 className={styles.text.h2}>Hello</h2>
<button className={styles.button.primaryNeon}>Click</button>
</div>
```
### 📖 Documentation
| Document | Purpose |
|----------|---------|
| **[Cheatsheet](./docs/STYLES_CHEATSHEET.md)** | 📋 Quick reference - copy/paste examples |
| **[Style Guide](./docs/STYLE_GUIDE.md)** | 📚 Complete guide with examples |
| **[Color Palette](./docs/COLOR_PALETTE.md)** | 🎨 Color meanings & usage |
**👉 Start with [Cheatsheet](./docs/STYLES_CHEATSHEET.md)** for quick examples!
---
## 🛠️ Tech Stack
- React 18 + Vite
- Tailwind CSS + Custom Design System
- Radix UI (accessibility)
- Lucide React (icons)
---
## 📁 Project Structure
```
src/
├── api/ API services
├── components/ Reusable components
├── lib/
│ ├── styles.js ⭐ Style presets (60+ ready-to-use)
│ └── utils.js Helper functions
├── pages/ Page components
├── index.css ⭐ Color variables (Mirage palette)
└── neon.css ⭐ Neon glow effects
```
---
## 📚 Learn More
- [Design System Overview](../DESIGN_SYSTEM_SUMMARY.md)
- [Vite](https://vitejs.dev/) | [React](https://react.dev/) | [Tailwind](https://tailwindcss.com/)
---
**Made with 💙 and neon ✨**

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "gray",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,259 +0,0 @@
# 🎨 Task&Coffee Color Palette
**Usage examples?** See [STYLE_GUIDE.md](./STYLE_GUIDE.md)
**Quick reference?** See [STYLES_CHEATSHEET.md](./STYLES_CHEATSHEET.md)
**Back to main?** See [README.md](../README.md)
---
## Mirage Palette - Visual Guide
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
MIRAGE COLOR SCALE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50 ░░░░░░░░░ #f4f6fb Lightest - heading text on dark bg
100 ▒▒▒▒▒▒▒▒▒ #e7edf7 Card foreground text
200 ▓▓▓▓▓▓▓▓▓ #cbd9ec Main foreground text
300 ▓▓▓▓▓▓▓▓▓ #9db8dc Muted foreground text
────────────────────────────────────────────────────────────
400 ████████ #6893c8 PRIMARY ACCENT ⭐
500 ████████ #4474b3 Accent hover state
────────────────────────────────────────────────────────────
600 ████████ #335b96 Secondary, elevated cards
700 ████████ #2a4a7a Dark cards alternative
800 ████████ #264066 Muted elements, popovers
900 ████████ #243756 CARDS ⭐
950 ████████ #111928 BACKGROUND (darkest) ⭐
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## 🌟 Semantic Color Mapping
### Background & Surfaces
```
--background #111928 (mirage-950) Main app background
--card #243756 (mirage-900) Card surfaces
--popover #264066 (mirage-800) Floating elements
--muted #264066 (mirage-800) Subtle backgrounds
--sidebar #111928 (mirage-950) Navigation rail
```
### Foreground & Text
```
--foreground #cbd9ec (mirage-200) Primary text
--card-foreground #e7edf7 (mirage-100) Text on cards
--muted-foreground #9db8dc (mirage-300) Secondary text
--popover-foreground #e7edf7 (mirage-100) Text in popovers
```
### Interactive Elements
```
--primary #6893c8 (mirage-400) Primary actions, links
--primary-foreground #111928 (mirage-950) Text on primary
--secondary #2a4a7a (mirage-700) Secondary actions
--accent #4474b3 (mirage-500) Hover states
```
### Borders & Inputs
```
--border rgba(157, 184, 220, 0.15) Subtle borders
--input rgba(157, 184, 220, 0.2) Input borders
--ring #6893c8 (mirage-400) Focus ring
```
---
## 💫 Neon Accent Colors
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NEON ACCENTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔵 Neon Blue #c6e2ff Light blue with glow effect
Glow color: rgba(30, 132, 242, 0.6)
💗 Neon Pink #ffc5ec Light pink with glow effect
Glow color: rgba(255, 20, 147, 0.6)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## 🎯 Usage by Element Type
### Text Colors
```
Headings: mirage-200 (#cbd9ec)
Body text: mirage-200 (#cbd9ec)
Secondary text: mirage-300 (#9db8dc)
Disabled text: mirage-300 (#9db8dc) with opacity
```
### Backgrounds
```
Page background: mirage-950 (#111928)
Card background: mirage-900 (#243756)
Hover background: mirage-800 (#264066)
Active background: mirage-700 (#2a4a7a)
```
### Buttons
```
Primary: mirage-400 (#6893c8) bg
Primary hover: mirage-500 (#4474b3) bg
Secondary: mirage-700 (#2a4a7a) bg
Secondary hover: mirage-600 (#335b96) bg
Ghost hover: mirage-800 (#264066) bg
```
### Borders
```
Default: mirage-300 with 15% opacity
Input: mirage-300 with 20% opacity
Focus: mirage-400 (#6893c8) solid
Divider: mirage-800 (#264066)
```
---
## 🌈 Color Relationships
```
LIGHT ↑
┌─────────────────────────────────┐
│ mirage-50 │ Lightest text │
│ mirage-100 │ Card text │
│ mirage-200 │ Main text │
│ mirage-300 │ Muted text │
├─────────────────────────────────┤
│ mirage-400 │ PRIMARY ⭐ │ ← Interactive
│ mirage-500 │ Hover state │
├─────────────────────────────────┤
│ mirage-600 │ Secondary │
│ mirage-700 │ Alt cards │
│ mirage-800 │ Muted bg │
│ mirage-900 │ CARDS ⭐ │ ← Surfaces
│ mirage-950 │ BACKGROUND ⭐ │
└─────────────────────────────────┘
DARK ↓
```
---
## 📊 Contrast Ratios (WCAG)
```
Background (950) + Text (200): AAA ✓ (14.2:1)
Card (900) + Card Text (100): AAA ✓ (12.8:1)
Primary (400) + Primary FG (950): AA ✓ (5.2:1)
Muted (800) + Muted Text (300): AA ✓ (4.8:1)
```
All color combinations meet WCAG AA standards minimum! 🎉
---
## 🎨 Example Compositions
### Card with Neon Accent
```
┌────────────────────────────────┐
│ mirage-900 background │
│ ┌──────────────────────────┐ │
│ │ mirage-100 text │ │
│ │ mirage-300 muted text │ │
│ │ │ │
│ │ [mirage-400 button] │ │
│ │ + neon blue glow │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
```
### Primary Button
```
┌──────────────────────┐
│ mirage-400 bg │ ← Background
│ mirage-950 text │ ← Text
│ + subtle shadow │
│ hover: neon glow │ ← Neon effect on hover
└──────────────────────┘
```
### Today's Task Column
```
┌────────────────────────────────┐
│ mirage-400/10 background │ ← 10% opacity
│ + mirage-400 border (2px) │ ← Solid border
│ + neon-blue glow │ ← Glow effect
│ │
│ Tasks: mirage-900 cards │
└────────────────────────────────┘
```
---
## 🔄 Dark Mode Toggle
When dark mode is active (`.dark` class):
```
Background: mirage-900 → mirage-950
Cards: mirage-700 → mirage-900
Primary: mirage-300 → mirage-400
Text: mirage-100 → mirage-200
```
Currently configured for dark-first design.
---
## 💡 Best Practices
1. **Depth hierarchy:**
- Background: mirage-950
- Elevated: mirage-900
- More elevated: mirage-800
2. **Text contrast:**
- Always use mirage-100/200 for text on dark backgrounds
- Use mirage-300 for less important text
3. **Neon sparingly:**
- Use neon effects only for:
- Brand elements (logo, headers)
- Important CTAs
- Today's tasks/current items
- Don't overuse - it should feel special
4. **Accessible focus states:**
- Always use mirage-400 for focus rings
- Add 3px ring with 50% opacity
---
## 🚀 Quick Reference
| Element | Light Value | Dark Value | Usage |
|---------|-------------|------------|-------|
| Page BG | `mirage-950` | `mirage-900` | Main background |
| Card BG | `mirage-900` | `mirage-700` | Surface |
| Text | `mirage-200` | `mirage-100` | Primary text |
| Primary | `mirage-400` | `mirage-300` | CTA buttons |
| Border | `mirage-300/15%` | `mirage-300/20%` | Dividers |
---
**See also:**
- `STYLE_GUIDE.md` - Complete style documentation
- `STYLES_CHEATSHEET.md` - Quick usage examples
- `src/lib/styles.js` - Tailwind class presets

View File

@@ -1,244 +0,0 @@
# Task&Coffee Styles Cheatsheet 🎨
Быстрая справка по использованию стилей в проекте.
**Full guide?** See [STYLE_GUIDE.md](./STYLE_GUIDE.md)
**Color reference?** See [COLOR_PALETTE.md](./COLOR_PALETTE.md)
**Back to main?** See [README.md](../README.md)
---
## Импорт
```javascript
import styles from '@/lib/styles';
import { cn } from '@/lib/utils';
```
---
## 📦 Карточки
```jsx
<div className={styles.card.filled}>Basic card</div>
<div className={styles.card.elevated}>Elevated card</div>
<div className={styles.card.task}>Task card</div>
<div className={styles.card.taskNeon}>Task card with glow</div>
```
---
## 🔘 Кнопки
```jsx
<button className={styles.button.primary}>Primary</button>
<button className={styles.button.primaryNeon}>Primary Neon</button>
<button className={styles.button.secondary}>Secondary</button>
<button className={styles.button.outline}>Outline</button>
<button className={styles.button.ghost}>Ghost</button>
<button className={styles.button.destructive}>Delete</button>
<button className={styles.button.icon}>🔍</button>
<button className={styles.button.iconNeon}></button>
```
---
## 📝 Inputs
```jsx
<input className={styles.input.default} />
<input className={styles.input.neon} />
<input className={styles.input.search} />
```
---
## 🏷️ Badges
```jsx
<span className={styles.badge.low}>Low</span>
<span className={styles.badge.medium}>Medium</span>
<span className={styles.badge.high}>High</span>
<span className={styles.badge.critical}>Critical</span>
<span className={styles.badge.success}>Success</span>
<span className={styles.badge.warning}>Warning</span>
<span className={styles.badge.error}>Error</span>
```
---
## ✨ Neon Effects (CSS Classes)
```jsx
<div className="neon-glow-blue">Blue glow on hover</div>
<div className="neon-glow-pink">Pink glow on hover</div>
<div className="neon-glow-soft">Soft glow</div>
<div className="neon-border-blue">Blue neon border</div>
<div className="neon-border-pink">Pink neon border</div>
<span className="neon-text-blue">Blue neon text</span>
<span className="neon-text-pink">Pink neon text</span>
<Icon className="neon-icon" />
```
---
## 🎭 Neon Animated Text
```jsx
<span className="sign-inline">Task&Coffee</span>
<span className="sign-pink-inline">Task&Coffee</span>
```
---
## 📐 Layout
```jsx
<div className={styles.layout.container}>
<aside className={styles.layout.sidebar}>Sidebar</aside>
<main className={styles.layout.main}>
<div className={styles.layout.content}>Content</div>
</main>
</div>
<div className={styles.layout.gridCards}>Cards grid</div>
```
---
## 🔤 Typography
```jsx
<h1 className={styles.text.h1}>Heading 1</h1>
<h2 className={styles.text.h2}>Heading 2</h2>
<p className={styles.text.body}>Body text</p>
<p className={styles.text.bodyMuted}>Muted text</p>
<small className={styles.text.small}>Small text</small>
```
---
## 🛠️ Utilities
```jsx
// Glow effects
<div className={styles.utility.glowBlue}>Blue glow</div>
<div className={styles.utility.glowPink}>Pink glow</div>
<div className={styles.utility.glowSoft}>Soft glow</div>
// Glass effect
<div className={styles.utility.glass}>Glassmorphism</div>
// Transitions
<div className={styles.utility.transition}>Smooth</div>
<div className={styles.utility.transitionSlow}>Slow</div>
// Focus ring
<button className={styles.utility.focusRing}>Accessible</button>
```
---
## 🎨 Цветовые переменные
### В CSS
```css
background: var(--color-background);
color: var(--color-foreground);
background: var(--color-card);
color: var(--color-primary);
border: 1px solid var(--color-border);
```
### В Tailwind
```jsx
<div className="bg-background text-foreground">
<div className="bg-card text-card-foreground">
<div className="bg-primary text-primary-foreground">
<div className="border border-border">
```
---
## 🔄 Комбинирование
```jsx
import { cn } from '@/lib/utils';
<div className={cn(
styles.card.task,
"neon-glow-soft",
isActive && "neon-border-blue"
)}>
Combined styles
</div>
```
---
## 📏 Радиусы
- `rounded-lg` = **0.5rem** ← используем для кнопок и карточек
- `rounded-xl` = 0.75rem
- `rounded-2xl` = 1rem
---
## 🎯 Полный пример компонента
```jsx
import styles from '@/lib/styles';
import { cn } from '@/lib/utils';
function TaskCard({ task, isToday }) {
return (
<div className={cn(
styles.card.task,
"neon-glow-soft",
isToday && "neon-border-blue"
)}>
<div className="flex items-center gap-2">
<span className={styles.badge[task.priority]}>
{task.priority}
</span>
<h3 className={styles.text.small}>{task.title}</h3>
</div>
<p className={styles.text.smallMuted}>{task.description}</p>
<button className={styles.button.primaryNeon}>
Complete
</button>
</div>
);
}
```
---
## 🌈 Mirage Colors
| Name | Hex | Usage |
|------|-----|-------|
| mirage-950 | `#111928` | Background (darkest) |
| mirage-900 | `#243756` | Cards |
| mirage-800 | `#264066` | Muted elements |
| mirage-700 | `#2a4a7a` | Secondary |
| mirage-600 | `#335b96` | Elevated cards |
| mirage-500 | `#4474b3` | Accent hover |
| mirage-400 | `#6893c8` | Primary accent |
| mirage-300 | `#9db8dc` | Muted text |
| mirage-200 | `#cbd9ec` | Main text |
| mirage-100 | `#e7edf7` | Card text |
| mirage-50 | `#f4f6fb` | Lightest text |
---
## 💡 Tips
1. Используйте готовые стили из `styles.js`
2. Комбинируйте с `cn()` для кастомизации
3. Неон - только для акцентов!
4. Всегда используйте CSS переменные
5. `rounded-lg` для консистентности
**Полная документация:** См. `STYLE_GUIDE.md`

View File

@@ -1,371 +0,0 @@
# Task&Coffee Style Guide
> Complete guide with examples and best practices
**Quick reference?** See [STYLES_CHEATSHEET.md](./STYLES_CHEATSHEET.md)
**Color details?** See [COLOR_PALETTE.md](./COLOR_PALETTE.md)
**Back to main?** See [README.md](../README.md)
---
## 📦 Using Styles
### Import
```javascript
import styles from '@/lib/styles';
import { cn } from '@/lib/utils';
```
### Примеры использования
#### 1. Карточки (Cards)
```jsx
// Filled card - основной вариант
<div className={cardStyles.filled}>
<h3>Card Title</h3>
<p>Card content...</p>
</div>
// Elevated card с тенью
<div className={cardStyles.elevated}>
<h3>Important Card</h3>
</div>
// Task card с неоновым эффектом
<div className={cardStyles.taskNeon}>
<p>Task description</p>
</div>
// Или используя cn helper
import { cn } from '@/lib/utils';
<div className={cn(cardStyles.filled, "additional-class")}>
Content
</div>
```
#### 2. Кнопки (Buttons)
```jsx
// Primary button
<button className={buttonStyles.primary}>
Submit
</button>
// Primary с неоновым эффектом
<button className={buttonStyles.primaryNeon}>
Create Task
</button>
// Secondary button
<button className={buttonStyles.secondary}>
Cancel
</button>
// Ghost button
<button className={buttonStyles.ghost}>
Learn More
</button>
// Icon button с неоновым эффектом
<button className={buttonStyles.iconNeon}>
<Icon />
</button>
```
#### 3. Layouts
```jsx
// Dashboard layout
<div className={layoutStyles.container}>
<aside className={layoutStyles.sidebar}>
{/* Sidebar content */}
</aside>
<main className={layoutStyles.main}>
<div className={layoutStyles.content}>
{/* Main content */}
</div>
</main>
</div>
// Grid для карточек
<div className={layoutStyles.gridCards}>
<Card />
<Card />
<Card />
</div>
```
#### 4. Inputs
```jsx
// Стандартный input
<input
type="text"
className={inputStyles.default}
placeholder="Enter text..."
/>
// Input с неоновым фокусом
<input
type="text"
className={inputStyles.neon}
placeholder="Task name..."
/>
// Search input
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="search"
className={inputStyles.search}
placeholder="Search tasks..."
/>
</div>
```
#### 5. Typography
```jsx
// Headings
<h1 className={textStyles.h1}>Main Title</h1>
<h2 className={textStyles.h2}>Section Title</h2>
// Body text
<p className={textStyles.body}>Regular text</p>
<p className={textStyles.bodyMuted}>Muted text</p>
// Неоновый текст (используется с neon.css)
<span className={cn(textStyles.neonBlue, "sign-inline")}>
Task&
</span>
<span className={cn(textStyles.neonPink, "sign-pink-inline")}>
Coffee
</span>
```
#### 6. Badges
```jsx
// Priority badges
<span className={badgeStyles.low}>Low</span>
<span className={badgeStyles.medium}>Medium</span>
<span className={badgeStyles.high}>High</span>
<span className={badgeStyles.critical}>Critical</span>
// Status badges
<span className={badgeStyles.success}>Completed</span>
<span className={badgeStyles.warning}>In Progress</span>
<span className={badgeStyles.error}>Failed</span>
```
---
## ✨ Неоновые эффекты
### CSS классы из neon.css
```jsx
// Неоновое свечение при hover - голубое
<div className="neon-glow-blue bg-card p-4 rounded-lg">
Card with blue glow on hover
</div>
// Неоновое свечение - розовое
<div className="neon-glow-pink bg-card p-4 rounded-lg">
Card with pink glow on hover
</div>
// Мягкое свечение
<button className="neon-glow-soft bg-primary px-4 py-2 rounded-lg">
Subtle glow
</button>
// Неоновая граница
<div className="neon-border-blue p-4 rounded-lg">
Border with neon effect
</div>
// Неоновый текст (без анимации)
<span className="neon-text-blue">
Static neon text
</span>
// Иконка с неоновым эффектом
<Icon className="neon-icon" />
```
### Анимированный неоновый текст
```jsx
// Для заголовков и брендинга
<div className="sign-inline">Coffee</div>
<div className="sign-pink-inline">Task&</div>
// Для больших заголовков (fullscreen)
<div className="sign">
<span>Task&Coffee</span>
</div>
```
---
## 🎭 Комбинирование стилей
### Пример: Карточка задачи с неоновым эффектом
```jsx
import { cn } from '@/lib/utils';
import { cardStyles, badgeStyles, textStyles } from '@/lib/styles';
function TaskCard({ task }) {
return (
<div className={cn(
cardStyles.task,
"neon-glow-soft",
task.isToday && "neon-border-blue"
)}>
<div className="flex items-center gap-2 mb-2">
<span className={badgeStyles[task.priority]}>
{task.priority}
</span>
<h3 className={textStyles.small}>
{task.title}
</h3>
</div>
<p className={textStyles.smallMuted}>
{task.description}
</p>
</div>
);
}
```
### Пример: Кнопка с неоновым эффектом и иконкой
```jsx
import { PlusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { buttonStyles } from '@/lib/styles';
function CreateButton() {
return (
<button className={cn(
buttonStyles.primaryNeon,
"gap-2"
)}>
<PlusIcon className="w-4 h-4" />
Create Task
</button>
);
}
```
---
## 🎨 Миграция с dashboard.css на Tailwind
### До (dashboard.css):
```css
.dashboard-task-card {
background: var(--color-card);
padding: 0.75rem;
border-radius: 0.5rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
```
### После (с использованием стилей):
```jsx
<div className={cardStyles.task}>
{/* content */}
</div>
// Или чистый Tailwind:
<div className="bg-card p-3 rounded-lg shadow-sm">
{/* content */}
</div>
```
---
## 📐 Радиусы округления
Теперь все радиусы синхронизированы:
- `rounded-sm` = `0.25rem` (4px)
- `rounded-md` = `0.375rem` (6px)
- `rounded-lg` = `0.5rem` (8px) ✅ **Используем для карточек и кнопок**
- `rounded-xl` = `0.75rem` (12px)
- `rounded-2xl` = `1rem` (16px)
Базовый радиус установлен в `--radius: 0.5rem` для консистентности с Material Design 3.
---
## 🔧 Утилиты
### Neon Glow Utilities
```javascript
import { utilityStyles } from '@/lib/styles';
<div className={cn(cardStyles.filled, utilityStyles.glowBlue)}>
Card with blue glow
</div>
```
### Glassmorphism
```jsx
<div className={cn(utilityStyles.glass, "p-6 rounded-lg")}>
Glass effect card
</div>
```
### Focus Ring
```jsx
<button className={cn(
buttonStyles.primary,
utilityStyles.focusRing
)}>
Accessible button
</button>
```
---
## 💡 Best Practices
1. **Используйте готовые стили** из `styles.js` для консистентности
2. **Комбинируйте с cn()** для добавления кастомных классов
3. **Неоновые эффекты** используйте умеренно - только для акцентов
4. **Радиусы** - используйте `rounded-lg` для основных элементов
5. **Цвета** - всегда используйте CSS переменные, не хардкодьте цвета
6. **Accessibility** - не забывайте про `focusRing` для интерактивных элементов
---
## 🚀 Быстрый старт
```jsx
// 1. Импортируйте стили
import styles from '@/lib/styles';
import { cn } from '@/lib/utils';
// 2. Используйте в компонентах
function MyComponent() {
return (
<div className={styles.layout.container}>
<div className={styles.card.filled}>
<h2 className={styles.text.h2}>Title</h2>
<p className={styles.text.bodyMuted}>Description</p>
<button className={styles.button.primaryNeon}>
Action
</button>
</div>
</div>
);
}
```
Теперь у вас есть полная система стилей, объединяющая Mirage palette, Material Design 3 и неоновую эстетику! 🎉

View File

@@ -1,39 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
// js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
"react/jsx-uses-react": "error",
'no-unused-vars': ['error', {
varsIgnorePattern: '^[A-Z_]',
argsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
])

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>taskncoffee-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +0,0 @@
{
"name": "taskncoffee-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.544.0",
"motion": "^12.23.22",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.4",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"vite": "^7.1.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 399 KiB

View File

@@ -1,35 +0,0 @@
import './App.css'
import { LoginPage } from './pages/Login'
import { SignUp } from './pages/SignUp'
import { AuthLayout } from './layouts/AuthLayout'
import { Routes, Route, Navigate } from 'react-router'
import Dashboard from './pages/Dashboard'
import RootRedirect from './pages/RootRedirect'
import { TaskButton } from './components/Card'
function App() {
return (
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/auth" element={
<AuthLayout className="flex min-h-svh flex-col items-center justify-center bg-muted text-foreground" />
}>
<Route path="login" element={<LoginPage />} />
<Route path="signup" element={<SignUp />} />
</Route>
<Route path="/dashboard" element={
<Dashboard />
} />
<Route path="/card" element={<TaskButton />} />
<Route path="/card/:id" element={
<TaskButton />
} />
<Route path="*" element={<Navigate to="/auth/login" replace />} />
</Routes>
)
}
export default App

View File

@@ -1,37 +0,0 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
const API_V1_PREFIX = '/api/v1';
const API_SOURCES = {
AUTH: `${API_BASE_URL}${API_V1_PREFIX}/auth`,
USERS: `${API_BASE_URL}${API_V1_PREFIX}/users`,
TASKS: `${API_BASE_URL}${API_V1_PREFIX}/tasks`,
}
const API_ENDPOINTS = {
AUTH: {
LOGIN: `${API_SOURCES.AUTH}/login`,
REFRESH: `${API_SOURCES.AUTH}/refresh`,
LOGOUT: `${API_SOURCES.AUTH}/logout`,
ME: `${API_SOURCES.AUTH}/me`,
REGISTER: `${API_SOURCES.AUTH}/signup`,
},
USERS: {
LIST: API_SOURCES.USERS,
BY_ID: (id) => `${API_SOURCES.USERS}/${id}`,
TASKS: (id) => `${API_SOURCES.USERS}/${id}/tasks`,
UPDATE: (id) => `${API_SOURCES.USERS}/${id}`,
DELETE: (id) => `${API_SOURCES.USERS}/${id}`,
},
TASKS: {
BY_ID: (id) => `${API_SOURCES.TASKS}/${id}`,
CREATE: `${API_SOURCES.TASKS}`,
UPDATE: (id) => `${API_SOURCES.TASKS}/${id}`,
DELETE: (id) => `${API_SOURCES.TASKS}/${id}`,
},
};
export { API_BASE_URL, API_V1_PREFIX, API_SOURCES, API_ENDPOINTS };

View File

@@ -1,100 +0,0 @@
import client from './client';
import { API_ENDPOINTS } from './ApiSources';
import { getFingerprint } from './fingerprint';
/**
* New user registration
* @param {Object} userData - User data
* @param {string} userData.username - Username
* @param {string} userData.password - Password
* @param {string} userData.email - Email (optional)
* @returns {Promise<Object>} Registered user data
*/
export const signup = async (userData) => {
try {
const response = await client.post(API_ENDPOINTS.AUTH.REGISTER, userData);
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};
/**
* Get current user information
* @returns {Promise<Object>} Current user information
*/
export const getMe = async () => {
try {
const response = await client.get(API_ENDPOINTS.AUTH.ME);
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};
/**
* User logout
* Backend automatically removes refresh token from httpOnly cookie
* @returns {Promise<Object>} Logout result
*/
export const logout = async () => {
try {
const response = await client.post(API_ENDPOINTS.AUTH.LOGOUT);
if (response.data.status_code === 200) {
localStorage.removeItem('access_token');
localStorage.removeItem('fingerprint');
}
return response.data;
} catch (error) {
localStorage.removeItem('access_token');
localStorage.removeItem('fingerprint');
throw error.response?.data || error.message;
}
};
/**
* User login
* @param {string} username - Username
* @param {string} password - Password
* @returns {Promise<Object>} Login result
*/
export const login = async (username, password) => {
try {
const fingerprint = await getFingerprint();
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
formData.append('fingerprint', fingerprint);
const response = await client.post(API_ENDPOINTS.AUTH.LOGIN, formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
});
console.log(response.data);
if (response.data.access_token && response.data.token_type === 'bearer') {
localStorage.setItem('access_token', response.data.access_token);
}
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};
export const refreshToken = async () => {
try {
const fingerprint = localStorage.getItem('fingerprint');
const response = await client.post(API_ENDPOINTS.AUTH.REFRESH, {
fingerprint: fingerprint,
});
if (response.data.access_token && response.data.token_type === 'bearer') {
localStorage.setItem('access_token', response.data.access_token);
}
return response.data;
} catch (error) {
localStorage.removeItem('access_token');
localStorage.removeItem('fingerprint');
throw error.response?.data || error.message;
}
};

View File

@@ -1,23 +0,0 @@
import axios from 'axios';
import { API_BASE_URL, API_V1_PREFIX } from './ApiSources';
const client = axios.create({
baseURL: `${API_BASE_URL}${API_V1_PREFIX}`,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
);
export default client;

View File

@@ -1,42 +0,0 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs';
let fpPromise = null;
/**
* @returns {Promise} Promise с экземпляром FingerprintJS
*/
const initFingerprint = () => {
if (!fpPromise) {
fpPromise = FingerprintJS.load();
}
return fpPromise;
};
/**
* @returns {Promise<string>} Fingerprint ID
*/
export const getFingerprint = async () => {
try {
const savedFingerprint = localStorage.getItem('fingerprint');
if (savedFingerprint) {
return savedFingerprint;
}
const fp = await initFingerprint();
const result = await fp.get();
const fingerprint = result.visitorId;
localStorage.setItem('fingerprint', fingerprint);
return fingerprint;
} catch (error) {
console.error('Error getting fingerprint:', error);
const fallbackFingerprint = `fallback_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('fingerprint', fallbackFingerprint);
return fallbackFingerprint;
}
};
export const clearFingerprint = () => {
localStorage.removeItem('fingerprint');
};

View File

@@ -1,22 +0,0 @@
import client from './client';
import { API_ENDPOINTS } from './ApiSources';
/**
* Create a new task
* @param {Object} taskData - Task data
* @param {string} taskData.title - Task title
* @param {string} taskData.description - Task description
* @param {string} taskData.priority - Task priority
* @param {string} taskData.status - Task status
* @param {string} taskData.due_date - Task due date
* @returns {Promise<Object>} Created task data
*/
export const createTask = async (taskData) => {
try {
const response = await client.post(API_ENDPOINTS.TASKS.CREATE, taskData);
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};

View File

@@ -1,44 +0,0 @@
import client from './client';
import { API_ENDPOINTS } from './ApiSources';
/**
* Get all users
* @returns {Promise<Object>} All users data
*/
export const getUsers = async () => {
try {
responce = await client.get(API_ENDPOINTS.USERS.LIST);
return responce.data;
} catch (error) {
throw error.response?.data || error.message;
}
};
/**
* Get user by id
* @param {number} id - User id
* @returns {Promise<Object>} User data
*/
export const getUserById = async (id) => {
try {
const response = await client.get(API_ENDPOINTS.USERS.BY_ID(id));
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};
/**
* Get all user tasks
* @param {number} id - User id
* @returns {Promise<Object>} All user tasks data
*/
export const getUserTasks = async (id) => {
try {
const response = await client.get(API_ENDPOINTS.USERS.TASKS(id));
return response.data;
} catch (error) {
throw error.response?.data || error.message;
}
};

View File

@@ -1,178 +0,0 @@
import { useState, useEffect } from 'react';
import { getUserTasks } from '@/api/users.service';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import styles from '@/lib/styles';
import { Plus } from 'lucide-react';
export default function Calendar() {
const [tasksFromBackend, setTasksFromBackend] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch tasks when component mounts
useEffect(() => {
const fetchTasks = async () => {
try {
console.log('Calendar: Fetching user tasks...');
const response = await getUserTasks(1);
console.log('Calendar: Tasks from backend:', response);
setTasksFromBackend(response);
} catch (error) {
console.error('Calendar: Failed to fetch tasks:', error);
} finally {
setLoading(false);
}
};
fetchTasks();
}, []);
const today = new Date();
const currentDay = today.getDay();
const currentDate = new Date(today);
const dayOffset = currentDay === 0 ? -6 : 1 - currentDay;
currentDate.setDate(today.getDate() + dayOffset);
const groupTasksByDay = (tasks) => {
const grouped = {};
tasks.forEach(task => {
const taskDate = new Date(task.due_date);
const dateKey = taskDate.toDateString();
if (!grouped[dateKey]) {
grouped[dateKey] = [];
}
grouped[dateKey].push({
id: task.id,
title: task.title,
priority: task.priority,
completed: task.status === 'closed' || task.status === 'completed',
dueDate: task.due_date
});
});
return grouped;
};
const groupedTasks = groupTasksByDay(tasksFromBackend);
const daysOfWeek = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
].map((name, index) => {
const date = new Date(currentDate);
date.setDate(currentDate.getDate() + index);
const dateKey = date.toDateString();
return {
name,
date: date.getDate(),
month: date.toLocaleDateString('en-US', { month: 'short' }),
fullDate: date,
tasks: groupedTasks[dateKey] || []
};
});
if (loading) {
return (
<div className={cn(styles.card.elevated, "flex-[3]")}>
<div className="flex items-center justify-center p-8">
<p className={styles.text.body}>Loading tasks...</p>
</div>
</div>
);
}
return (
<div className={cn(
styles.utility.glass,
"p-6 rounded-2xl flex-3",
styles.utility.transitionSlow
)}>
<div className="flex gap-3 h-full overflow-x-auto px-3">
{daysOfWeek.map((day) => {
const isToday = day.fullDate.toDateString() === today.toDateString();
return (
<div
key={day.name}
className={cn(
"flex-1 min-w-[180px] max-w-[200px] flex flex-col rounded-2xl p-4 transition-all duration-300",
isToday
? "bg-primary/10 border-2 border-primary neon-glow-soft"
: "bg-background border border-border"
)}
>
{/* Header */}
<div className="text-center pb-3 border-b border-border mb-3">
<h3 className={cn(
styles.text.small,
"font-semibold mb-1",
isToday && "text-primary font-bold"
)}>
{day.name}
</h3>
<p className={cn(
styles.text.smallMuted,
isToday && "text-primary font-semibold"
)}>
{day.month} {day.date}
</p>
</div>
{/* Tasks */}
<div className="flex-1 flex flex-col gap-2 overflow-y-auto pt-1">
{day.tasks.map((task) => (
<div
key={task.id}
className={cn(
styles.card.task,
task.completed && "opacity-60 line-through"
)}
>
<div className="flex items-center gap-2">
{/* Priority indicator */}
<span
className={cn(
"w-1.5 h-1.5 rounded-full flex-shrink-0",
task.priority === 'low' && "bg-green-500",
task.priority === 'medium' && "bg-yellow-500",
task.priority === 'high' && "bg-orange-500",
task.priority === 'critical' && "bg-red-500"
)}
/>
<p className={cn(styles.text.small, "flex-1")}>
{task.title}
</p>
</div>
</div>
))}
{/* Add button */}
<button
className={cn(
styles.button.iconNeon,
"mt-2 w-full flex items-center justify-center gap-2",
)}
onClick={() => {
console.log('Add task clicked for', day.name);
}}
>
<Plus className="w-4 h-4" />
<span className="text-xs">Add Task</span>
</button>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,139 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router'
import styles from '@/lib/styles'
import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
export function TaskButton({ taskData }) {
const navigate = useNavigate()
const location = useLocation()
const [isModalOpen, setIsModalOpen] = useState(false)
const taskId = taskData?.id || 1
// Проверяем, открыта ли карточка по URL
useEffect(() => {
const isCardRoute = location.pathname === `/card/${taskId}`
setIsModalOpen(isCardRoute)
}, [location.pathname, taskId])
const openModal = () => {
navigate(`/card/${taskId}`)
}
const closeModal = () => {
navigate(-1) // Возвращаемся на предыдущую страницу
}
return (
<>
<div
key={taskData?.id || 1}
className={cn(
styles.card.task,
"w-[110px] cursor-pointer hover:opacity-80 transition-opacity"
)}
onClick={openModal}
>
{taskData?.title || "Sample task card"}
</div>
{isModalOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={closeModal}
>
<div onClick={(e) => e.stopPropagation()}>
<CardComponent
status={taskData?.status}
time_spent={taskData?.time_spent}
description={taskData?.description}
title={taskData?.title}
priority={taskData?.priority}
due_date={taskData?.due_date}
/>
</div>
</div>
)}
</>
)
}
export function CardComponent({
status = "todo",
time_spent = 0,
description = "No description",
title = "Task",
priority = "medium",
due_date = null
}) {
const formatTimeSpent = (minutes) => {
if (!minutes || minutes === 0) return "0 min";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
return `${mins}m`;
};
const formatDate = (dateString) => {
if (!dateString) return "No due date";
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const priorityBadgeStyle = {
low: styles.badge.low,
medium: styles.badge.medium,
high: styles.badge.high,
critical: styles.badge.critical,
};
const statusLabels = {
open: "Open",
closed: "Closed",
in_progress: "In Progress",
todo: "To Do"
};
return (
<div className={cn(styles.card.filled, "flex flex-col w-full min-w-[400px] max-w-[600px] h-[400px] overflow-hidden")}>
<div className="flex items-end flex-1 -mt-6 -mx-6 bg-center bg-cover bg-[url('/images/a.jpg')]">
<h1 className={cn(styles.text.h1, "pl-6")}>{title}</h1>
</div>
<div className="flex-1 flex flex-col gap-3 z-10 mt-6">
<div className="flex items-center gap-2 flex-wrap">
<span className={cn(priorityBadgeStyle[priority])}>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</span>
<span className={cn(styles.text.smallMuted)}>
Status: {statusLabels[status]}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className={cn(styles.text.smallMuted)}>
{formatTimeSpent(time_spent)}
</span>
<span className={cn(styles.text.smallMuted)}>
📅 {formatDate(due_date)}
</span>
</div>
<p className={cn(styles.text.smallMuted, "flex-1 line-clamp-3 overflow-auto")}>
{description}
</p>
<button className={cn(styles.button.primaryNeon, "w-[110px]")}>
<span className={styles.text.neonBlue}>Complete</span>
</button>
</div>
</div>
)
}

View File

@@ -1,42 +0,0 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
export function useRipple() {
const [ripples, setRipples] = useState([])
const addRipple = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const id = Date.now()
setRipples((prev) => [...prev, { x, y, id }])
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id))
}, 600)
}
const Ripples = (
<AnimatePresence>
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.6 }}
animate={{ scale: 4, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
style={{
left: ripple.x,
top: ripple.y,
}}
className="absolute h-8 w-8 rounded-full bg-white/70 pointer-events-none -translate-x-1/2 -translate-y-1/2"
/>
))}
</AnimatePresence>
)
return { addRipple, Ripples }
}

View File

@@ -1,46 +0,0 @@
"use client"
import React, { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
export default function RippleButton({ children }) {
const [ripples, setRipples] = useState([])
const addRipple = (e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const id = Date.now()
setRipples([...ripples, { x, y, id }])
setTimeout(() => {
setRipples((r) => r.filter((ripple) => ripple.id !== id))
}, 600) // ripple длится ~600ms
}
return (
<button
onClick={addRipple}
className="relative overflow-hidden rounded-lg bg-blue-600 px-4 py-2 text-white font-medium"
>
{children}
<AnimatePresence>
{ripples.map((ripple) => (
<motion.span
key={ripple.id}
initial={{ scale: 0, opacity: 0.6 }}
animate={{ scale: 4, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
style={{
left: ripple.x,
top: ripple.y,
}}
className="absolute h-8 w-8 rounded-full bg-white/70 pointer-events-none -translate-x-1/2 -translate-y-1/2"
/>
))}
</AnimatePresence>
</button>
)
}

View File

@@ -1,45 +0,0 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
);
}
function AvatarImage({
className,
...props
}) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props} />
);
}
function AvatarFallback({
className,
...props
}) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props} />
);
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,58 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
elevated:
"bg-card p-3 rounded-lg shadow-sm text-sm transition-transform hover:-translate-y-0.5 hover:shadow-md",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }

View File

@@ -1,101 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
...props
}) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props} />
);
}
export { Input }

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props} />
);
}
export { Label }

View File

@@ -1,189 +0,0 @@
'use client';;
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Home, Briefcase, Calendar, Shield, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
const defaultItems = [
{ label: 'home', icon: Home },
{ label: 'work', icon: Briefcase },
{ label: 'calendar', icon: Calendar },
{ label: 'security', icon: Shield },
{ label: 'settings', icon: Settings },
];
export const MenuDock = ({
items,
className,
variant = 'default',
orientation = 'horizontal',
showLabels = true,
animated = true,
showIndicator = true
}) => {
const finalItems = useMemo(() => {
const isValid = items && Array.isArray(items) && items.length >= 2 && items.length <= 8;
if (!isValid) {
console.warn(
"MenuDock: 'items' prop is invalid or missing. Using default items.",
items
);
return defaultItems;
}
return items;
}, [items]);
const [activeIndex, setActiveIndex] = useState(0);
const [underlineWidth, setUnderlineWidth] = useState(0);
const [underlineLeft, setUnderlineLeft] = useState(0);
const textRefs = useRef([]);
const itemRefs = useRef([]);
useEffect(() => {
if (activeIndex >= finalItems.length) {
setActiveIndex(0);
}
}, [finalItems, activeIndex]);
useEffect(() => {
const updateUnderline = () => {
const activeButton = itemRefs.current[activeIndex];
const activeText = textRefs.current[activeIndex];
if (activeButton && activeText && showLabels && orientation === 'horizontal') {
const buttonRect = activeButton.getBoundingClientRect();
const textRect = activeText.getBoundingClientRect();
const containerRect = activeButton.parentElement?.getBoundingClientRect();
if (containerRect) {
setUnderlineWidth(textRect.width);
setUnderlineLeft(
buttonRect.left - containerRect.left + (buttonRect.width - textRect.width) / 2
);
}
}
};
updateUnderline();
window.addEventListener('resize', updateUnderline);
return () => window.removeEventListener('resize', updateUnderline);
}, [activeIndex, finalItems, showLabels, orientation]);
const handleItemClick = (index, item) => {
setActiveIndex(index);
item.onClick?.();
};
const getVariantStyles = () => {
switch (variant) {
case 'compact':
return {
container: 'p-1 gap-3',
item: 'px-5 py-2',
icon: 'h-4 w-4',
text: 'text-xs'
};
case 'large':
return {
container: 'p-3 gap-4',
item: 'px-6 py-4',
icon: 'h-6 w-6',
text: 'text-base'
};
default:
return {
container: 'p-2 gap-3',
item: 'px-5 py-3.5',
icon: 'h-5 w-5',
text: 'text-sm'
};
}
};
const styles = getVariantStyles();
return (
<nav
className={cn(
'relative inline-flex items-center rounded-3xl bg-transparent',
orientation === 'horizontal' ? 'flex-row' : 'flex-col',
styles.container,
className
)}
role="navigation">
{finalItems.map((item, index) => {
const isActive = index === activeIndex;
const IconComponent = item.icon;
return (
<div
key={`${item.label}-${index}`}
className="flex flex-col items-center gap-1">
<button
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
'relative flex items-center justify-center rounded-full transition-all duration-300',
'hover:bg-primary/10 focus-visible:outline-none',
'select-none active:scale-95',
styles.item,
isActive && 'text-primary bg-primary/15',
!isActive && 'text-muted-foreground hover:text-foreground'
)}
onClick={() => handleItemClick(index, item)}
aria-label={item.label}
type="button">
<div
className={cn(
'flex items-center justify-center transition-all duration-200',
animated && isActive && 'animate-bounce'
)}>
<IconComponent className={cn(styles.icon, 'transition-colors duration-200')} />
</div>
</button>
{showLabels && (
<span
ref={(el) => { textRefs.current[index] = el; }}
className={cn(
'font-medium transition-colors duration-200 capitalize',
styles.text,
'whitespace-nowrap',
isActive ? 'text-primary' : 'text-muted-foreground'
)}>
{item.label}
</span>
)}
</div>
);
})}
{/* Animated underline for horizontal orientation with labels */}
{showIndicator && showLabels && orientation === 'horizontal' && (
<div
className={cn(
'absolute bottom-2 h-0.5 bg-primary rounded-full transition-all duration-300 ease-out',
animated ? 'transition-all duration-300' : ''
)}
style={{
width: `${underlineWidth}px`,
left: `${underlineLeft}px`,
}} />
)}
{/* Active indicator for vertical orientation or no labels */}
{showIndicator && (!showLabels || orientation === 'vertical') && (
<div
className={cn(
'absolute bg-primary rounded-full transition-all duration-300',
orientation === 'vertical'
? 'left-1 w-1 h-6'
: 'bottom-0.5 h-0.5 w-6'
)}
style={{
[orientation === 'vertical' ? 'top' : 'left']:
orientation === 'vertical'
? `${(activeIndex * (variant === 'large' ? 64 : variant === 'compact' ? 56 : 60)) + (variant === 'large' ? 19 : variant === 'compact' ? 16 : 18)}px`
: `${(activeIndex * (variant === 'large' ? 64 : variant === 'compact' ? 56 : 60)) + (variant === 'large' ? 19 : variant === 'compact' ? 16 : 18)}px`
}} />
)}
</nav>
);
};

View File

@@ -1,169 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #111928;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Mirage Palette Colors */
--mirage-50: #f4f6fb;
--mirage-100: #e7edf7;
--mirage-200: #cbd9ec;
--mirage-300: #9db8dc;
--mirage-400: #6893c8;
--mirage-500: #4474b3;
--mirage-600: #335b96;
--mirage-700: #2a4a7a;
--mirage-800: #264066;
--mirage-900: #243756;
--mirage-950: #111928;
/* Neon Accent Colors */
--neon-blue: #c6e2ff;
--neon-blue-glow: rgba(30, 132, 242, 0.6);
--neon-pink: #ffc5ec;
--neon-pink-glow: rgba(255, 20, 147, 0.6);
/* Material Design 3 Semantic Colors with Mirage Palette */
--radius: 0.5rem;
--background: #111928; /* mirage-950 - самый темный для фона */
--foreground: #cbd9ec; /* mirage-200 - светлый текст */
--card: #243756; /* mirage-900 - карточки темнее фона */
--card-foreground: #e7edf7; /* mirage-100 - текст на карточках */
--popover: #264066; /* mirage-800 - всплывающие элементы */
--popover-foreground: #e7edf7; /* mirage-100 */
--primary: #6893c8; /* mirage-400 - основной акцент (средний синий) */
--primary-foreground: #111928; /* mirage-950 - темный текст на акценте */
--secondary: #2a4a7a; /* mirage-700 - вторичный цвет */
--secondary-foreground: #e7edf7; /* mirage-100 */
--muted: #264066; /* mirage-800 - приглушенные элементы */
--muted-foreground: #9db8dc; /* mirage-300 - приглушенный текст */
--accent: #4474b3; /* mirage-500 - акцент при наведении */
--accent-foreground: #f4f6fb; /* mirage-50 */
--destructive: #ef4444; /* красный для удаления */
--destructive-foreground: #f4f6fb;
--border: rgba(157, 184, 220, 0.15); /* mirage-300 с прозрачностью */
--input: rgba(157, 184, 220, 0.2); /* чуть плотнее для полей ввода */
--ring: #6893c8; /* mirage-400 - фокус как primary */
/* Sidebar */
--sidebar: #111928; /* mirage-950 - как фон */
--sidebar-foreground: #cbd9ec; /* mirage-200 */
--sidebar-primary: #6893c8; /* mirage-400 */
--sidebar-primary-foreground: #111928;
--sidebar-accent: #335b96; /* mirage-600 */
--sidebar-accent-foreground: #e7edf7;
--sidebar-border: rgba(157, 184, 220, 0.1);
--sidebar-ring: #6893c8;
/* Chart Colors - используем палитру */
--chart-1: #6893c8; /* mirage-400 */
--chart-2: #4474b3; /* mirage-500 */
--chart-3: #335b96; /* mirage-600 */
--chart-4: #9db8dc; /* mirage-300 */
--chart-5: #2a4a7a; /* mirage-700 */
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
/* Темная тема немного светлее основной (для toggle режима) */
--background: #243756; /* mirage-900 */
--foreground: #e7edf7; /* mirage-100 */
--card: #2a4a7a; /* mirage-700 */
--card-foreground: #f4f6fb; /* mirage-50 */
--popover: #264066; /* mirage-800 */
--popover-foreground: #e7edf7;
--primary: #9db8dc; /* mirage-300 - светлее для контраста */
--primary-foreground: #111928;
--secondary: #335b96; /* mirage-600 */
--secondary-foreground: #f4f6fb;
--muted: #264066;
--muted-foreground: #9db8dc;
--accent: #6893c8; /* mirage-400 */
--accent-foreground: #111928;
--destructive: #ef4444;
--destructive-foreground: #f4f6fb;
--border: rgba(157, 184, 220, 0.2);
--input: rgba(157, 184, 220, 0.25);
--ring: #9db8dc;
--chart-1: #9db8dc;
--chart-2: #6893c8;
--chart-3: #4474b3;
--chart-4: #cbd9ec;
--chart-5: #335b96;
--sidebar: #243756;
--sidebar-foreground: #e7edf7;
--sidebar-primary: #9db8dc;
--sidebar-primary-foreground: #111928;
--sidebar-accent: #4474b3;
--sidebar-accent-foreground: #f4f6fb;
--sidebar-border: rgba(157, 184, 220, 0.15);
--sidebar-ring: #9db8dc;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,13 +0,0 @@
import { Outlet } from 'react-router'
const AuthLayout = ({ className }) => {
return (
<div className={className}>
<div className="w-full max-w-sm">
<Outlet />
</div>
</div>
)
}
export { AuthLayout }

View File

@@ -1,168 +0,0 @@
/**
* Tailwind CSS Class Presets for Task&Coffee
* Material Design 3 + Neon Aesthetic
* Using Mirage Color Palette
*/
// Card Styles - Material Design 3 Variants
export const cardStyles = {
// Filled card - основной вариант для большинства карточек
filled: "bg-card text-card-foreground p-6 rounded-lg shadow-sm",
// Elevated card - карточки с эффектом поднятия
elevated: "bg-card text-card-foreground p-6 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300",
// Outlined card - карточки с границей
outlined: "bg-background border-2 border-border text-foreground p-6 rounded-lg",
// Task card - компактные карточки для задач
task: "bg-card text-card-foreground p-3 rounded-lg shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200",
// Task card with neon accent
taskNeon: "bg-card text-card-foreground p-3 rounded-lg shadow-sm hover:shadow-[0_0_15px_rgba(104,147,200,0.3)] hover:-translate-y-0.5 transition-all duration-200",
};
// Button Styles
export const buttonStyles = {
// Primary button - основные действия
primary: "bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md",
// Primary with neon glow
primaryNeon: "bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:shadow-[0_0_20px_rgba(104,147,200,0.5)]",
// Secondary button - вторичные действия
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 px-4 py-2 rounded-lg font-medium transition-all duration-200",
// Outline button - менее важные действия
outline: "border-2 border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground px-4 py-2 rounded-lg font-medium transition-all duration-200",
// Ghost button - минималистичные кнопки
ghost: "text-foreground hover:bg-accent hover:text-accent-foreground px-4 py-2 rounded-lg font-medium transition-all duration-200",
// Destructive button - опасные действия
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 px-4 py-2 rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md",
// Icon button
icon: "p-2 rounded-lg hover:bg-accent transition-all duration-200 hover:-translate-y-0.5",
// Icon button with neon effect
iconNeon: "p-2 rounded-lg hover:bg-accent transition-all duration-200 hover:shadow-[0_0_15px_rgba(104,147,200,0.4)] hover:-translate-y-0.5",
};
// Layout Styles
export const layoutStyles = {
// Main container
container: "flex min-h-screen bg-background text-foreground",
// Sidebar/Navigation rail
sidebar: "w-20 bg-sidebar border-r border-sidebar-border flex flex-col items-center py-4 gap-4",
// Main content area
main: "flex-1 p-6 bg-background flex flex-col",
// Content wrapper with max width
content: "max-w-7xl mx-auto w-full flex flex-1 flex-col gap-6",
// Grid layouts
gridCards: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4",
gridTasks: "grid grid-cols-1 gap-3",
};
// Input Styles
export const inputStyles = {
// Standard input
default: "w-full px-4 py-2 bg-background border border-input rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all duration-200",
// Input with neon focus effect
neon: "w-full px-4 py-2 bg-background border border-input rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:shadow-[0_0_15px_rgba(104,147,200,0.3)] transition-all duration-200",
// Search input
search: "w-full px-4 py-2 pl-10 bg-background border border-input rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-all duration-200",
};
// Text Styles
export const textStyles = {
// Headings
h1: "text-4xl font-bold text-foreground",
h2: "text-3xl font-semibold text-foreground",
h3: "text-2xl font-semibold text-foreground",
h4: "text-xl font-semibold text-foreground",
// Body text
body: "text-base text-foreground",
bodyMuted: "text-base text-muted-foreground",
// Small text
small: "text-sm text-foreground",
smallMuted: "text-sm text-muted-foreground",
// Neon text (to be combined with neon.css animations)
neonBlue: "text-[#c6e2ff]",
neonPink: "text-[#ffc5ec]",
};
// Badge/Chip Styles
export const badgeStyles = {
// Default badge
default: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-primary/20 text-primary border border-primary/30 cursor-default select-none",
// Priority badges
low: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30 cursor-default select-none",
medium: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 cursor-default select-none",
high: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-orange-500/20 text-orange-400 border border-orange-500/30 cursor-default select-none",
critical: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30 cursor-default select-none",
// Status badges
success: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30",
warning: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30",
error: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30",
info: "inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-500/20 text-blue-400 border border-blue-500/30",
};
// Utility Styles
export const utilityStyles = {
// Neon glow effects (for hover states)
glowBlue: "hover:shadow-[0_0_20px_rgba(104,147,200,0.6)]",
glowPink: "hover:shadow-[0_0_20px_rgba(255,20,147,0.6)]",
glowSoft: "hover:shadow-[0_0_15px_rgba(104,147,200,0.3)]",
// Glassmorphism effect
glass: "bg-card/50 backdrop-blur-md border border-border/50",
// Smooth transitions
transition: "transition-all duration-200 ease-in-out",
transitionSlow: "transition-all duration-300 ease-in-out",
// Focus visible states
focusRing: "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
};
// Complete component compositions
export const componentStyles = {
// Day column in calendar
dayColumn: "flex-1 min-w-[180px] flex flex-col bg-background rounded-lg p-4 transition-all duration-300",
dayColumnToday: "flex-1 min-w-[180px] flex flex-col bg-primary/10 border-2 border-primary rounded-lg p-4 transition-all duration-300",
// Avatar
avatar: "w-14 h-14 rounded-lg overflow-hidden",
avatarLarge: "w-20 h-20 rounded-xl overflow-hidden",
// Divider
divider: "h-px bg-border w-full",
dividerVertical: "w-px bg-border h-full",
};
// Export all styles as a single object for convenience
export const styles = {
card: cardStyles,
button: buttonStyles,
layout: layoutStyles,
input: inputStyles,
text: textStyles,
badge: badgeStyles,
utility: utilityStyles,
component: componentStyles,
};
export default styles;

View File

@@ -1,39 +0,0 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
import { jwtDecode as decode } from "jwt-decode";
import { refreshToken } from "../api/auth.service";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
let refreshPromise = null;
export async function jwtexp(token) {
const decoded = decode(token);
const currentTime = Date.now() / 1000;
const tokenExp = decoded.exp;
if (tokenExp < currentTime || tokenExp - currentTime < 120) {
if (refreshPromise) {
console.log("Refresh already in progress, waiting...");
return await refreshPromise;
}
refreshPromise = (async () => {
try {
await refreshToken();
return true;
} catch (error) {
console.error("Failed to refresh token:", error);
return false;
} finally {
refreshPromise = null;
}
})();
return await refreshPromise;
}
return true;
}

View File

@@ -1,11 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { BrowserRouter } from 'react-router'
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
)

View File

@@ -1,221 +0,0 @@
@font-face {
font-family: 'Vibur';
src: url('./assets/fonts/vibur.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fabulous';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./assets/fonts/Fabulous.ttf') format('truetype');
}
@font-face {
font-family: 'Carry-You';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./assets/fonts/Carry-You.ttf') format('truetype');
}
/*-- Sign Styles --*/
.sign {
font-family: "Carry-You", cursive;
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 5.6rem;
text-align: center;
line-height: 1;
color: #c6e2ff;
animation: neon .08s ease-in-out infinite alternate;
}
}
.sign-pink {
font-family: "Carry-You", cursive;
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span {
font-size: 5.6rem;
text-align: center;
line-height: 1;
color: #ffc5ec;
animation: neon-pink .08s ease-in-out infinite alternate;
}
}
/*-- Inline Sign Styles for Headers --*/
.sign-inline {
font-family: "Carry-You", cursive;
font-size: 3rem;
line-height: 1;
color: #c6e2ff;
animation: neon .08s ease-in-out infinite alternate;
}
.sign-pink-inline {
font-family: "Carry-You", cursive;
font-size: 3rem;
line-height: 1;
color: #ffc5ec;
animation: neon-pink .08s ease-in-out infinite alternate;
}
@keyframes neon {
from {
text-shadow:
0 0 6px rgba(202,228,225,0.92),
0 0 30px rgba(202,228,225,0.34),
0 0 12px rgba(30,132,242,0.52),
0 0 21px rgba(30,132,242,0.92),
0 0 34px rgba(30,132,242,0.78),
0 0 54px rgba(30,132,242,0.92);
}
to {
text-shadow:
0 0 6px rgba(202,228,225,0.98),
0 0 30px rgba(202,228,225,0.42),
0 0 12px rgba(30,132,242,0.58),
0 0 22px rgba(30,132,242,0.84),
0 0 38px rgba(30,132,242,0.88),
0 0 60px rgba(30,132,242,1);
}
}
@keyframes neon-pink {
from {
text-shadow:
0 0 6px rgba(255,182,193,0.92),
0 0 30px rgba(255,182,193,0.34),
0 0 12px rgba(255,20,147,0.52),
0 0 21px rgba(255,20,147,0.92),
0 0 34px rgba(255,20,147,0.78),
0 0 54px rgba(255,20,147,0.92);
}
to {
text-shadow:
0 0 6px rgba(255,182,193,0.98),
0 0 30px rgba(255,182,193,0.42),
0 0 12px rgba(255,20,147,0.58),
0 0 22px rgba(255,20,147,0.84),
0 0 38px rgba(255,20,147,0.88),
0 0 60px rgba(255,20,147,1);
}
}
.link {
position: absolute;
bottom: 10px; left: 10px;
color: #828282;
text-decoration: none;
&:focus,
&:hover {
color: #c6e2ff;
text-shadow:
0 0 2px rgba(202,228,225,0.92),
0 0 10px rgba(202,228,225,0.34),
0 0 4px rgba(30,132,242,0.52),
0 0 7px rgba(30,132,242,0.92),
0 0 11px rgba(30,132,242,0.78),
0 0 16px rgba(30,132,242,0.92);
}
}
/*-- Utility Neon Classes for Cards and Buttons --*/
/* Neon glow on hover - blue */
.neon-glow-blue {
transition: box-shadow 0.3s ease-in-out;
}
.neon-glow-blue:hover {
box-shadow: 0 0 20px rgba(104, 147, 200, 0.6),
0 0 30px rgba(104, 147, 200, 0.4),
0 0 40px rgba(104, 147, 200, 0.2);
}
/* Neon glow on hover - pink */
.neon-glow-pink {
transition: box-shadow 0.3s ease-in-out;
}
.neon-glow-pink:hover {
box-shadow: 0 0 20px rgba(255, 20, 147, 0.6),
0 0 30px rgba(255, 20, 147, 0.4),
0 0 40px rgba(255, 20, 147, 0.2);
}
/* Soft neon glow - for subtle effects */
.neon-glow-soft {
transition: box-shadow 0.3s ease-in-out;
}
.neon-glow-soft:hover {
box-shadow: 0 0 15px rgba(104, 147, 200, 0.3),
0 0 25px rgba(104, 147, 200, 0.2);
}
/* Neon border - blue */
.neon-border-blue {
border: 1px solid rgba(104, 147, 200, 0.4);
box-shadow: 0 0 10px rgba(104, 147, 200, 0.2);
transition: all 0.3s ease-in-out;
}
.neon-border-blue:hover {
border-color: rgba(104, 147, 200, 0.8);
box-shadow: 0 0 15px rgba(104, 147, 200, 0.4),
0 0 25px rgba(104, 147, 200, 0.2);
}
/* Neon border - pink */
.neon-border-pink {
border: 1px solid rgba(255, 20, 147, 0.4);
box-shadow: 0 0 10px rgba(255, 20, 147, 0.2);
transition: all 0.3s ease-in-out;
}
.neon-border-pink:hover {
border-color: rgba(255, 20, 147, 0.8);
box-shadow: 0 0 15px rgba(255, 20, 147, 0.4),
0 0 25px rgba(255, 20, 147, 0.2);
}
/* Text with subtle neon effect (non-animated) */
.neon-text-blue {
color: #c6e2ff;
text-shadow: 0 0 10px rgba(30, 132, 242, 0.5),
0 0 20px rgba(30, 132, 242, 0.3);
}
.neon-text-pink {
color: #ffc5ec;
text-shadow: 0 0 10px rgba(255, 20, 147, 0.5),
0 0 20px rgba(255, 20, 147, 0.3);
}
/* Icon with neon glow on hover */
.neon-icon {
transition: filter 0.3s ease-in-out;
}
.neon-icon:hover {
filter: drop-shadow(0 0 8px rgba(104, 147, 200, 0.8))
drop-shadow(0 0 15px rgba(104, 147, 200, 0.5));
}

View File

@@ -1,120 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import '../neon.css';
import { MenuDock } from '@/components/ui/shadcn-io/menu-dock';
import { Home, Settings, Bell } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { jwtexp, cn } from '@/lib/utils';
import Calendar from '@/components/Calendar';
import { useNavigate } from "react-router";
import styles from '@/lib/styles';
const menuItems = [
{ label: 'home', icon: Home, onClick: () => console.log('Home clicked') },
{ label: 'notify', icon: Bell, onClick: () => console.log('Notifications clicked') },
{ label: 'settings', icon: Settings, onClick: () => console.log('Settings clicked') },
];
export default function Dashboard() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState(false);
// Validate token on page load
useEffect(() => {
console.log('Dashboard useEffect triggered');
const validateAuth = async () => {
try {
console.log('Validating authentication...');
const token = localStorage.getItem('access_token');
if (token) {
console.log('Token found, validating...');
const isTokenValid = await jwtexp(token);
if (!isTokenValid) {
console.error('Token validation failed');
setAuthError(true);
setLoading(false);
return;
}
} else {
console.error('No access token found');
setAuthError(true);
setLoading(false);
return;
}
console.log('Authentication validated successfully');
} catch (error) {
console.error('Authentication validation error:', error);
setAuthError(true);
} finally {
setLoading(false);
}
};
validateAuth();
}, []);
// Redirect to login if authentication fails
useEffect(() => {
if (authError) {
console.log('Authentication error detected, redirecting to login...');
navigate('/auth/login', { replace: true });
}
}, [authError, navigate]);
if (loading || authError) {
return (
<div className={styles.layout.container}>
<div className="flex items-center justify-center min-h-screen">
<p className={styles.text.h3}>
{loading ? 'Loading dashboard...' : 'Redirecting...'}
</p>
</div>
</div>
);
}
return (
<div className={styles.layout.container}>
{/* Navigation Rail - Material Design 3 */}
<aside className={styles.layout.sidebar}>
{/* Avatar */}
<div className="mb-2">
<Avatar className={styles.component.avatar}>
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
</div>
<MenuDock
items={menuItems}
orientation="vertical"
animated={false}
showIndicator={false}
showLabels={true}
variant="compact"
/>
</aside>
{/* Main Content Area */}
<main className={styles.layout.main}>
<div className={styles.layout.content}>
{/* Header with neon text */}
<h1 className={cn(styles.text.h1, "mb-6")}>
<span className="sign-pink-inline">Task&</span>
<span className="sign-inline">Coffee</span>
</h1>
{/* Calendar component */}
<Calendar />
{/* Spacer */}
<div className="flex-[0.5]"></div>
</div>
</main>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { login } from "@/api/auth.service"
import { Link, useNavigate } from "react-router"
export function LoginPage({ className }) {
const navigate = useNavigate()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const handleSubmit = async (e) => {
e.preventDefault()
setError("")
setSuccess("")
setIsLoading(true)
try {
const result = await login(username, password)
setSuccess("Login successful!")
console.log("Logged in:", result)
// Redirect to dashboard after successful login
navigate('/dashboard', { replace: true })
} catch (err) {
setError(err.detail || "Login failed. Please check your credentials.")
console.error("Login error:", err)
} finally {
setIsLoading(false)
}
}
return (
<div className={className}>
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your username below to login to your account
</CardDescription>
<CardAction>
<Button variant="link">
<Link to="/auth/signup">Sign Up</Link>
</Button>
</CardAction>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6">
{error && (
<div className="text-sm text-red-500 bg-red-50 p-2 rounded">
{error}
</div>
)}
{success && (
<div className="text-sm text-green-500 bg-green-50 p-2 rounded">
{success}
</div>
)}
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex-col gap-2">
<Button
type="submit"
className="w-full"
onClick={handleSubmit}
disabled={isLoading}
>
{isLoading ? "Loading..." : "Login"}
</Button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { jwtexp } from '@/lib/utils';
export default function RootRedirect() {
const navigate = useNavigate();
const [checking, setChecking] = useState(true);
useEffect(() => {
const checkAuthAndRedirect = async () => {
try {
const token = localStorage.getItem('access_token');
if (token) {
const isTokenValid = await jwtexp(token);
if (isTokenValid) {
navigate('/dashboard', { replace: true });
} else {
console.error('Token is invalid, redirecting...');
navigate('/auth/login', { replace: true });
}
} else {
console.error('No token found, redirecting...');
navigate('/auth/login', { replace: true });
}
} catch (error) {
console.error('Error checking auth:', error);
navigate('/auth/login', { replace: true });
} finally {
setChecking(false);
}
};
checkAuthAndRedirect();
}, [navigate]);
if (checking) {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-xl">Checking authentication...</p>
</div>
);
}
return null;
}

View File

@@ -1,67 +0,0 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Link } from "react-router"
const SignUp = ({
heading = "Signup",
logo = {
// url: "https://www.shadcnblocks.com",
// src: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/logos/shadcnblockscom-wordmark.svg",
alt: "logo",
title: "shadcnblocks.com",
},
buttonText = "Create Account",
signupText = "Already a user?",
}) => {
return (
<section className="bg-muted h-screen">
<div className="flex h-full items-center justify-center">
{/* Logo */}
<div className="flex flex-col items-center gap-6 lg:justify-start">
<a href={logo.url}>
<img
src={logo.src}
alt={logo.alt}
title={logo.title}
className="h-10 dark:invert"
/>
</a>
<div className="min-w-sm border-muted bg-background flex w-full max-w-sm flex-col items-center gap-y-4 rounded-md border px-6 py-8 shadow-md">
{heading && <h1 className="text-xl font-semibold">{heading}</h1>}
<Input
type="email"
placeholder="Email"
className="text-sm"
required
/>
<Input
type="password"
placeholder="Password"
className="text-sm"
required
/>
<Input
type="password"
placeholder="Confirm Password"
className="text-sm"
required
/>
<Button type="submit" className="w-full">
{buttonText}
</Button>
</div>
<div className="text-muted-foreground flex justify-center gap-1 text-sm">
<p>{signupText}</p>
<a
className="text-primary font-medium hover:underline"
>
<Link to="/auth/login">Login</Link>
</a>
</div>
</div>
</div>
</section>
);
};
export { SignUp };

View File

@@ -1,13 +0,0 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

View File

@@ -1,3 +1,4 @@
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import NullPool, insert from sqlalchemy import NullPool, insert

View File

@@ -1,4 +1,3 @@
import pytest
from httpx import AsyncClient from httpx import AsyncClient
from src.core.settings import settings from src.core.settings import settings
@@ -16,30 +15,14 @@ async def test_registration(ac):
assert result.json()["is_active"] assert result.json()["is_active"]
@pytest.mark.parametrize( async def test_login(ac: AsyncClient):
"fingerprint, username,password,expected_status",
[("string", "kot", "P@ssw0rd", 200), ("", "kot", "P@ssw0rd", 422)],
)
async def test_login(
ac: AsyncClient,
fingerprint: str,
username: str,
password: str,
expected_status: int,
):
result = await ac.post( result = await ac.post(
f"{settings.api.v1_login_url}/login", f"{settings.api.v1_login_url}/login",
data={ data={
"fingerprint": fingerprint,
"grant_type": "password", "grant_type": "password",
"username": username, "username": "kot",
"password": password, "password": "P@ssw0rd",
}, },
) )
assert result.status_code == expected_status assert result.status_code == 200
if expected_status == 200: assert result.json().get("access_token")
assert result.json().get("access_token") is not None
else:
json_response = result.json()
if expected_status == 422:
assert "detail" in json_response

View File

@@ -55,32 +55,3 @@ async def test_tasks_user(db: "TestDBManager"):
user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id) user_with_tasks = await db.user.get_one_with_load(user_id=admin_user.id)
assert user_with_tasks assert user_with_tasks
assert user_with_tasks.tasks assert user_with_tasks.tasks
async def test_tasks_crud(db: "TestDBManager"):
data = {
"title": "test_tasks_crud",
"description": "test",
"due_date": datetime.now(),
"status": "open",
"priority": "medium",
"user_id": 1,
}
task = await db.task.create_one(data)
assert task
assert task.title == data["title"]
assert task.description == data["description"]
assert task.due_date == data["due_date"].date()
assert task.status == data["status"]
assert task.priority == data["priority"]
assert task.user_id == data["user_id"]
assert task.created_at is not None
find_task = await db.task.get_filtered(title=data["title"])
assert find_task
assert find_task[0].title == task.title
data["title"] = "test2"
task = await db.task.update_one(id=task.id, data=data)
assert task.title == data["title"]
await db.task.delete_one(id=task.id)
task = await db.task.get_one_or_none(id=task.id)
assert not task