Compare commits

...

67 Commits

Author SHA1 Message Date
IluaAir
96ae1ef37c add route to cards 2025-12-09 23:59:04 +03:00
IluaAir
461e9a8105 Refactor Card component to introduce TaskButton for modal functionality and enhance layout with dynamic task data display. 2025-12-06 23:54:32 +03:00
IluaAir
cb73cd219b Update Card component to use a background image and adjust layout for improved aesthetics and responsiveness. 2025-11-19 01:45:37 +03:00
IluaAir
0d92ea9893 Refactor Card component layout and styles, adding an image background and improving badge styles for better user experience. 2025-11-14 23:30:12 +03:00
IluaAir
1a675834fe add first card 2025-11-14 00:43:25 +03:00
IluaAir
9f7e91d0a9 Update button styles in Calendar component to use neon effect for enhanced visual appeal 2025-10-28 23:47:18 +03:00
IluaAir
69b2571dd4 Update icon button styles to include hover translation effect for improved user interaction 2025-10-28 23:45:03 +03:00
IluaAir
bfd8c04a90 Refactor Calendar component styles, bigger columns and container glass stile 2025-10-28 23:29:08 +03:00
IluaAir
0b7a1a72d3 fiont style 2025-10-27 23:41:12 +03:00
IluaAir
9820437556 Enhance documentation and styling system with Mirage Palette, Material Design 3, and Neon accents. Add comprehensive style guide, color palette, and cheatsheet for improved developer experience. 2025-10-27 23:11:11 +03:00
IluaAir
cad5cfa780 begin new color schema 2025-10-26 23:48:06 +03:00
IluaAir
afb356944d fix radius 2025-10-26 23:39:21 +03:00
IluaAir
166bbee1d5 update 2025-10-25 23:32:23 +03:00
IluaAir
c7efdb89c2 add buttons 2025-10-23 22:24:03 +03:00
IluaAir
0c15be3b38 del logs from jwtexp 2025-10-23 22:04:24 +03:00
IluaAir
246cd54a47 full user login flow, redirect user from root path to dashboard 2025-10-21 22:40:37 +03:00
IluaAir
17b64121db Enhance Dashboard component to redirect on authentication error and streamline loading state display 2025-10-21 22:29:56 +03:00
IluaAir
692618ea21 Implement task fetching in Calendar component with loading state 2025-10-19 23:18:24 +03:00
IluaAir
024fa8b366 Add Calendar component to Dashboard for task organization by week 2025-10-19 23:10:10 +03:00
IluaAir
6f2ca0a891 Update dashboard styles and remove unnecessary border from week grid 2025-10-19 00:22:12 +03:00
IluaAir
fd8105d30a Refactor Dashboard component to handle authentication errors and improve token validation logic 2025-10-19 00:14:34 +03:00
IluaAir
6b241bcfe5 add refresh token api call 2025-10-18 23:27:30 +03:00
IluaAir
5e229585ef util for checkin' jwt validation and refresh 2025-10-18 23:10:32 +03:00
IluaAir
6f49d2b171 Implement api task fetching in Dashboard component with loading state 2025-10-16 23:41:10 +03:00
IluaAir
2722488a4e simple jwt token validation from frontend 2025-10-15 23:03:24 +03:00
IluaAir
7768f94122 user tasks 2025-10-15 22:47:07 +03:00
IluaAir
4830d2a432 tasks create 2025-10-15 22:43:52 +03:00
IluaAir
19321e3ea2 refactor apiv1 to api 2025-10-15 22:39:26 +03:00
IluaAir
ffe9a27921 fix tasks container padding 2025-10-15 22:36:46 +03:00
IluaAir
53270970cb add critical style 2025-10-15 20:07:04 +03:00
IluaAir
6712c189c0 Enhance dashboard layout and styles with task grouping and priority indicators 2025-10-15 20:04:34 +03:00
IluaAir
b816906c5c tasks 2025-10-15 10:07:28 +03:00
IluaAir
c647f837e8 dashboard css 2025-10-13 23:24:51 +03:00
IluaAir
80b40ce1c0 off animation 2025-10-12 23:38:22 +03:00
IluaAir
36926c9974 inline for neon and update dashboard styles 2025-10-12 23:29:16 +03:00
IluaAir
be3181ca00 menydesk material-desgn 3 2025-10-12 23:20:03 +03:00
IluaAir
8ee524318c menydock update, dashboard page create 2025-10-12 22:52:48 +03:00
IluaAir
2c35b781dd start creating dashboard 2025-10-10 23:58:57 +03:00
IluaAir
31e8e355f3 add jwt-decode 2025-10-10 00:07:38 +03:00
IluaAir
23945b3487 fix layout 2025-10-09 23:55:20 +03:00
IluaAir
b4f98fe6cd navigation login and signup 2025-10-09 23:51:00 +03:00
IluaAir
1f351bc32b router and fix backend cors 2025-10-09 23:31:32 +03:00
IluaAir
3708a612f7 fix api sources 2025-10-09 23:00:57 +03:00
IluaAir
4fe2d39fba login axios 2025-10-09 22:51:50 +03:00
IluaAir
15426adc69 add auth service 2025-10-06 23:32:34 +03:00
IluaAir
eee26d06b7 add fingerprint 2025-10-06 23:09:38 +03:00
IluaAir
e0f22b6c5a add axios client with interceptors 2025-10-06 23:09:25 +03:00
IluaAir
4a33b8defa add api sources 2025-10-06 00:41:57 +03:00
IluaAir
7978b1042d ripple button 2025-10-05 00:16:20 +03:00
IluaAir
33bd88629f add login card and shadcn components 2025-10-04 23:54:19 +03:00
IluaAir
e41b8922e5 add neon and font style 2025-10-04 02:04:51 +03:00
IluaAir
4211be86ff init frontend 2025-10-04 00:03:42 +03:00
IluaAir
88ace64d13 add priority filter 2025-09-30 22:19:43 +03:00
IluaAir
9decb7591e change admin dep 2025-09-30 22:09:26 +03:00
IluaAir
41c64127a5 del old task dep, page start from 1 2025-09-30 00:23:16 +03:00
IluaAir
ac28f9b420 add limit page date to date from 2025-09-30 00:01:13 +03:00
IluaAir
91daaf9275 delete protocol 2025-09-28 22:34:14 +03:00
IluaAir
64dcc77518 delete old dep 2025-09-28 22:20:34 +03:00
IluaAir
ddaa5b0c74 fix dep user for endpoints 2025-09-28 22:18:23 +03:00
IluaAir
23927e5347 add id tasks endpoint 2025-09-27 23:58:39 +03:00
IluaAir
67a570ceac format and login exception 2025-09-27 12:07:31 +03:00
IluaAir
966b59ff20 fix max age 2025-09-27 12:00:17 +03:00
IluaAir
b87c37b149 add test fingerprint 2025-09-27 11:52:55 +03:00
IluaAir
4e47ce80fd fix fingerprint body 2025-09-27 11:26:10 +03:00
IluaAir
f4e46bef22 full refresh token 2025-09-27 11:24:57 +03:00
IluaAir
3dc36a2f25 add fingerprint httpbearer schema 2025-09-21 21:35:46 +03:00
IluaAir
408e32d05f add expire and fingerprint for refreshed 2025-09-21 19:54:17 +03:00
66 changed files with 9287 additions and 181 deletions

View File

@@ -50,6 +50,22 @@ ___
### Frontend
- **React.js**
- **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,9 +5,7 @@ from fastapi import Depends, Query
from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator
from src.schemas.tasks import TaskFilter
TaskFilterDep = Annotated[TaskFilter, Depends()]
from src.schemas.tasks import PriorityEnum, StatusEnum
class Date(BaseModel):
@@ -23,4 +21,21 @@ class Date(BaseModel):
return self
DateDep = Annotated[Date, Depends()]
class Page(BaseModel):
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,19 +1,21 @@
from typing import Annotated
from fastapi import Depends, HTTPException, Path
from fastapi.security import HTTPBearer, OAuth2PasswordBearer
from fastapi import Depends, HTTPException
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager
from src.core.settings import settings
from src.schemas.auth import TokenData
from src.services.tasks import TaskService
from src.services.users import UserService
http_bearer = HTTPBearer(auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login")
AccessTokenDep = Annotated[str, Depends(oauth2_scheme)]
AccessTokenDep = Annotated[HTTPAuthorizationCredentials, Depends(http_bearer)]
async def get_current_user(
@@ -25,13 +27,13 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = AuthManager.decode_access_token(token, verify_exp)
payload = AuthManager.decode_access_token(token.credentials, verify_exp)
if payload is None:
raise credentials_exception
user = TokenData(**payload)
if check_active and not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
except InvalidTokenError:
except (InvalidTokenError, AttributeError):
raise credentials_exception
return user
@@ -48,48 +50,14 @@ async def get_current_user_for_refresh(token: AccessTokenDep):
return await get_current_user(token, verify_exp=False, check_active=True)
async def get_current_user_for_admin(token: AccessTokenDep):
admin = await get_current_user(token, verify_exp=True, check_active=True)
if not admin.is_superuser:
raise HTTPException(status_code=403, detail="Admin access required")
return admin
CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)]
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)]
async def get_admin_user(db: sessionDep, current_user: ActiveUser):
await UserService(db).validate_admin_user(current_user.sub)
return current_user
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
async def CurrentOrAdminOwner(
db: sessionDep, current_user: ActiveUser, id: Annotated[int, Path()]
):
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)]
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)]

View File

@@ -1,6 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
from fastapi import APIRouter, Body, Cookie, Depends, Form, HTTPException, Response
from fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep
@@ -27,15 +27,18 @@ async def login(
session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response,
fingerprint: str = Form(min_length=5),
):
result = await AuthService(session).login(credential.username, credential.password)
result = await AuthService(session).login(
credential.username, credential.password, fingerprint=fingerprint
)
response.set_cookie(
key="refresh_token",
value=result["refresh_token"],
httponly=True,
samesite="lax",
path=settings.api.v1.auth,
max_age=60 * 60 * 24 * 7,
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result
@@ -43,20 +46,23 @@ async def login(
@router.post(path="/refresh", response_model=Token)
async def refresh(
session: sessionDep,
response: Response,
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)
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.auth,
max_age=60 * 60 * 24 * 7,
path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
)
return result

View File

@@ -1,28 +1,20 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends
from fastapi import APIRouter, Body, Depends, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.task_dep import TaskFilterDep
from src.api.dependacies.user_dep import ActiveUser, TaskOwnerDep
from src.api.dependacies.user_dep import ActiveUser
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService
from src.services.users import UserService
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}")
async def get_task_id(session: sessionDep, id: int, _: TaskOwnerDep):
async def get_task_id(session: sessionDep, id: int, user: ActiveUser):
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
@@ -42,17 +34,26 @@ async def post_task(
async def patch_task(
session: sessionDep,
id: int,
_: TaskOwnerDep,
user: ActiveUser,
task_data: TaskPATCHRequest = Body(),
):
task = await TaskService(session).update_task(id, task_data)
return task
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")
updated_task = await TaskService(session).update_task(id, task_data)
return updated_task
@router.delete("/{id}")
async def delete_task(
session: sessionDep,
id: int,
_: TaskOwnerDep,
user: ActiveUser,
):
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)
return {"message": "Task deleted successfully"}

View File

@@ -1,9 +1,10 @@
from fastapi import APIRouter, Body
from fastapi import APIRouter, Body, HTTPException
from src.api.dependacies.db_dep import sessionDep
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import (
ActiveUser,
AdminUser,
OwnerDep,
)
from src.core.settings import settings
from src.schemas.users import UserUpdate
@@ -19,18 +20,30 @@ async def get_all_users(session: sessionDep, _: AdminUser):
@router.get("/{id}")
async def get_user_by_id(session: sessionDep, id: int, _: OwnerDep):
async def get_user_by_id(session: sessionDep, id: int, _: AdminUser):
user = await UserService(session).get_user_by_filter_or_raise(id=id)
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}")
async def patch_user(
session: sessionDep,
id: int,
_: OwnerDep,
user: ActiveUser,
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(
id=id, update_data=user_update
)
@@ -38,6 +51,6 @@ async def patch_user(
@router.delete("/{id}")
async def delete_user(session: sessionDep, id: int, _: AdminUser):
async def delete_user(session: sessionDep, id: int, user: AdminUser):
await UserService(session).delete_user(id)
return {"message": "User deleted successfully"}

View File

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

View File

@@ -8,10 +8,6 @@ if TYPE_CHECKING:
from src.repository.users import UsersRepo
class HasId(Protocol):
id: Any
class IUOWDB(Protocol):
session: AsyncSession
user: "UsersRepo"

View File

@@ -28,22 +28,34 @@ class ApiPrefix(BaseModel):
class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_prefix="ACCESS_TOKEN_"
)
class AccessToken(BaseModel):
expire_minutes: int
secret_key: str
algorithm: str = "HS256"
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):
api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings()
access_token: AccessToken = AccessToken() # type: ignore
access_token: AccessToken
refresh_token: RefreshToken
cors_settings: CorsSettings
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_nested_delimiter="__"
)
settings = Settings()
settings = Settings() # type: ignore

View File

@@ -5,10 +5,21 @@ import uvicorn
from fastapi import FastAPI
sys.path.append(str(Path(__file__).parent.parent))
from fastapi.middleware.cors import CORSMiddleware
from src.api import router
from src.core.settings import settings
app = FastAPI(title="Task&Coffee")
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__":
uvicorn.run("src.main:app", port=8000, log_level="info", reload=True)

View File

@@ -0,0 +1,45 @@
"""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,7 +1,10 @@
from sqlalchemy import ForeignKey, Integer, String
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):
@@ -9,3 +12,10 @@ class RefreshTokensORM(Base):
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

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
from datetime import date
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
@@ -9,21 +8,24 @@ from src.models.tasks import TasksORM
from src.repository.base import BaseRepo
class UsersRepo(BaseRepo):
class UsersRepo(BaseRepo[UsersORM]):
model: type[UsersORM] = UsersORM
async def get_one_with_load(
self,
user_id: int,
status: str | None = None,
tasks_limit: Optional[int] = None,
tasks_offset: Optional[int] = 0,
date_to: Optional[date] = None,
date_from: Optional[date] = None,
priority: str | None = None,
tasks_limit: int | None = None,
tasks_offset: int | None = 0,
date_to: date | None = None,
date_from: date | None = None,
) -> UsersORM | None:
filters_sq: dict = {"user_id": user_id}
if status:
filters_sq["status"] = status
if priority:
filters_sq["priority"] = priority
tasks_subquery = self._tasks_subquary(
date_from=date_from, date_to=date_to, **filters_sq
)

View File

@@ -50,11 +50,3 @@ class Task(TaskADDRequest):
time_spent: int
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

@@ -19,51 +19,50 @@ class AuthService(BaseService):
await self.session.commit()
return User.model_validate(result)
async def login(self, username: str, password: str):
result = await self.session.user.get_one_or_none(username=username)
if result is None:
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
user = UserWithHashedPass.model_validate(result)
user_token = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password
)
if not verify or user.is_active is False:
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
access_token = AuthManager.create_access_token(user_token.model_dump())
async def _tokens_create(self, user_data: TokenData, fingerprint: str):
access_token = AuthManager.create_access_token(user_data.model_dump())
refresh_token = AuthManager.create_refresh_token()
await self.session.auth.create_one({"token": refresh_token, "user_id": user.id})
await self.session.commit()
await self.session.auth.create_one({
"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 delete_token(self, token: str) -> None:
await self.session.auth.delete_one(token=token)
async def login(self, username: str, password: str, fingerprint: str) -> dict:
login_exception = HTTPException(
status_code=401,
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)
token_data = TokenData.model_validate(user.model_dump())
verify = AuthManager.verify_password(
plain_password=password, hashed_password=user.hashed_password
)
if not verify or user.is_active is False:
raise login_exception
tokens = await self._tokens_create(token_data, fingerprint)
await self.session.commit()
return tokens
async def refresh_tokens(self, refresh_token: str, user_data: TokenData):
async def refresh_tokens(
self, refresh_token: str, user_data: TokenData, fingerprint: str
) -> dict:
token_record = await self.session.auth.get_one_or_none(token=refresh_token)
if not token_record or token_record.user_id != user_data.id:
raise HTTPException(status_code=401, detail="Invalid refresh token")
new_access_token = AuthManager.create_access_token(user_data.model_dump())
new_refresh_token = AuthManager.create_refresh_token()
token = await self._tokens_create(user_data, fingerprint)
await self.session.auth.delete_one(token=refresh_token)
await self.session.auth.create_one({
"token": new_refresh_token,
"user_id": user_data.id,
})
await self.session.commit()
return {
"access_token": new_access_token,
"token_type": settings.access_token.token_type,
"refresh_token": new_refresh_token,
}
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,13 +1,10 @@
from fastapi import HTTPException
from src.models.tasks import TasksORM
from src.schemas.tasks import Task, TaskADDRequest, TaskPATCHRequest
from src.services.base import BaseService
class TaskService(BaseService):
model = TasksORM
async def create_task(self, user_id: int, task_data: TaskADDRequest) -> Task:
user = await self.session.user.get_one_or_none(id=user_id)
if user is None:
@@ -29,10 +26,13 @@ class TaskService(BaseService):
await self.session.commit()
async def update_task(
self, task_id: int, task_data: TaskPATCHRequest, exclude_unset: bool = True
self,
task_id: int,
task_data: TaskPATCHRequest,
exclude_unset: bool = True,
):
task = await self.session.task.update_one(
id=task_id, data=task_data.model_dump(exclude_unset=exclude_unset)
data=task_data.model_dump(exclude_unset=exclude_unset), id=task_id
)
await self.session.commit()
return Task.model_validate(task)

View File

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

24
taskncoffee-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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?

76
taskncoffee-app/README.md Normal file
View File

@@ -0,0 +1,76 @@
# 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

@@ -0,0 +1,22 @@
{
"$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

@@ -0,0 +1,259 @@
# 🎨 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

@@ -0,0 +1,244 @@
# 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

@@ -0,0 +1,371 @@
# 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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,13 @@
<!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

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

5776
taskncoffee-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"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.

After

Width:  |  Height:  |  Size: 399 KiB

View File

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,100 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,44 @@
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;
}
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,178 @@
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

@@ -0,0 +1,139 @@
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

@@ -0,0 +1,42 @@
"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

@@ -0,0 +1,46 @@
"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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,101 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,189 @@
'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

@@ -0,0 +1,169 @@
@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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,168 @@
/**
* 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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,221 @@
@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

@@ -0,0 +1,120 @@
'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

@@ -0,0 +1,123 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,67 @@
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

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