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

View File

@@ -5,9 +5,7 @@ from fastapi import Depends, Query
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from pydantic import BaseModel, model_validator from pydantic import BaseModel, model_validator
from src.schemas.tasks import TaskFilter from src.schemas.tasks import PriorityEnum, StatusEnum
TaskFilterDep = Annotated[TaskFilter, Depends()]
class Date(BaseModel): class Date(BaseModel):
@@ -23,4 +21,21 @@ class Date(BaseModel):
return self 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 typing import Annotated
from fastapi import Depends, HTTPException, Path from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, OAuth2PasswordBearer from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
OAuth2PasswordBearer,
)
from jwt import InvalidTokenError from jwt import InvalidTokenError
from src.api.dependacies.db_dep import sessionDep
from src.core.auth_manager import AuthManager from src.core.auth_manager import AuthManager
from src.core.settings import settings from src.core.settings import settings
from src.schemas.auth import TokenData from src.schemas.auth import TokenData
from src.services.tasks import TaskService
from src.services.users import UserService
http_bearer = HTTPBearer(auto_error=False) http_bearer = HTTPBearer(auto_error=False)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api.v1_login_url}/login") 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( async def get_current_user(
@@ -25,13 +27,13 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = AuthManager.decode_access_token(token, verify_exp) payload = AuthManager.decode_access_token(token.credentials, verify_exp)
if payload is None: if payload is None:
raise credentials_exception raise credentials_exception
user = TokenData(**payload) user = TokenData(**payload)
if check_active and not user.is_active: if check_active and not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
except InvalidTokenError: except (InvalidTokenError, AttributeError):
raise credentials_exception raise credentials_exception
return user 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) 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)] CurrentUser = Annotated[TokenData, Depends(get_current_user_basic)]
ActiveUser = Annotated[TokenData, Depends(get_current_active_user)] ActiveUser = Annotated[TokenData, Depends(get_current_active_user)]
RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)] RefreshUser = Annotated[TokenData, Depends(get_current_user_for_refresh)]
AdminUser = Annotated[TokenData, Depends(get_current_user_for_admin)]
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)]

View File

@@ -1,6 +1,6 @@
from typing import Annotated 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 fastapi.security import OAuth2PasswordRequestForm
from src.api.dependacies.db_dep import sessionDep from src.api.dependacies.db_dep import sessionDep
@@ -27,15 +27,18 @@ async def login(
session: sessionDep, session: sessionDep,
credential: Annotated[OAuth2PasswordRequestForm, Depends()], credential: Annotated[OAuth2PasswordRequestForm, Depends()],
response: Response, 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( response.set_cookie(
key="refresh_token", key="refresh_token",
value=result["refresh_token"], value=result["refresh_token"],
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path=settings.api.v1.auth, path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * 7, max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
) )
return result return result
@@ -43,20 +46,23 @@ async def login(
@router.post(path="/refresh", response_model=Token) @router.post(path="/refresh", response_model=Token)
async def refresh( async def refresh(
session: sessionDep, session: sessionDep,
response: Response,
current_user: RefreshUser, current_user: RefreshUser,
response: Response,
fingerprint: Annotated[str, Body(embed=True)],
refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None, refresh_token: Annotated[str | None, Cookie(name="refresh_token")] = None,
): ):
if refresh_token is None: if refresh_token is None:
raise HTTPException(status_code=401, detail="No refresh token") 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( response.set_cookie(
key="refresh_token", key="refresh_token",
value=result["refresh_token"], value=result["refresh_token"],
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path=settings.api.v1.auth, path=settings.api.v1_login_url,
max_age=60 * 60 * 24 * 7, max_age=60 * 60 * 24 * settings.refresh_token.expire_days,
) )
return result return result

View File

@@ -1,28 +1,20 @@
from typing import Annotated 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.db_dep import sessionDep
from src.api.dependacies.task_dep import TaskFilterDep from src.api.dependacies.user_dep import ActiveUser
from src.api.dependacies.user_dep import ActiveUser, TaskOwnerDep
from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest from src.schemas.tasks import TaskADDRequest, TaskPATCHRequest
from src.services.tasks import TaskService from src.services.tasks import TaskService
from src.services.users import UserService
router = APIRouter(prefix="/tasks", tags=["Tasks"]) router = APIRouter(prefix="/tasks", tags=["Tasks"])
@router.get("/")
async def get_tasks(session: sessionDep, user: ActiveUser, filter: TaskFilterDep):
result = await UserService(session).get_user_with_tasks(
user_id=user.id, **filter.model_dump(exclude_unset=True)
)
return result
@router.get("/{id}") @router.get("/{id}")
async def get_task_id(session: sessionDep, id: int, _: TaskOwnerDep): async def get_task_id(session: sessionDep, id: int, user: ActiveUser):
task = await TaskService(session).get_task(id) task = await TaskService(session).get_task(id)
if task.user_id != user.id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
return task return task
@@ -42,17 +34,26 @@ async def post_task(
async def patch_task( async def patch_task(
session: sessionDep, session: sessionDep,
id: int, id: int,
_: TaskOwnerDep, user: ActiveUser,
task_data: TaskPATCHRequest = Body(), task_data: TaskPATCHRequest = Body(),
): ):
task = await TaskService(session).update_task(id, task_data) if user.is_superuser is False:
return task 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}") @router.delete("/{id}")
async def delete_task( async def delete_task(
session: sessionDep, session: sessionDep,
id: int, 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) 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.db_dep import sessionDep
from src.api.dependacies.task_dep import FilterDep
from src.api.dependacies.user_dep import ( from src.api.dependacies.user_dep import (
ActiveUser,
AdminUser, AdminUser,
OwnerDep,
) )
from src.core.settings import settings from src.core.settings import settings
from src.schemas.users import UserUpdate from src.schemas.users import UserUpdate
@@ -19,18 +20,30 @@ async def get_all_users(session: sessionDep, _: AdminUser):
@router.get("/{id}") @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) user = await UserService(session).get_user_by_filter_or_raise(id=id)
return user return user
@router.get("/{id}/tasks")
async def get_user_tasks(
session: sessionDep, id: int, user: ActiveUser, filters: FilterDep
):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
tasks = await UserService(session).get_user_with_tasks(id, **filters.model_dump())
return tasks.tasks
@router.patch("/{id}") @router.patch("/{id}")
async def patch_user( async def patch_user(
session: sessionDep, session: sessionDep,
id: int, id: int,
_: OwnerDep, user: ActiveUser,
user_update: UserUpdate = Body(), user_update: UserUpdate = Body(),
): ):
if user.id != id and user.is_superuser is False:
raise HTTPException(status_code=403, detail="Forbidden")
updated_user = await UserService(session).update_user( updated_user = await UserService(session).update_user(
id=id, update_data=user_update id=id, update_data=user_update
) )
@@ -38,6 +51,6 @@ async def patch_user(
@router.delete("/{id}") @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) await UserService(session).delete_user(id)
return {"message": "User deleted successfully"} 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 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") @event.listens_for(engine.sync_engine, "connect")

View File

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

View File

@@ -28,22 +28,34 @@ class ApiPrefix(BaseModel):
class DbSettings(BaseModel): class DbSettings(BaseModel):
url: str = f"sqlite+aiosqlite:///{DB_PATH}" url: str = f"sqlite+aiosqlite:///{DB_PATH}"
echo: bool = False
class AccessToken(BaseSettings): class AccessToken(BaseModel):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", env_prefix="ACCESS_TOKEN_"
)
expire_minutes: int expire_minutes: int
secret_key: str secret_key: str
algorithm: str = "HS256" algorithm: str = "HS256"
token_type: str = "bearer" # noqa: S105 token_type: str = "bearer" # noqa: S105
class RefreshToken(BaseModel):
expire_days: int
class CorsSettings(BaseModel):
production: str
local: list[str] = ["http://localhost:5173", "http://localhost:5174"]
class Settings(BaseSettings): class Settings(BaseSettings):
api: ApiPrefix = ApiPrefix() api: ApiPrefix = ApiPrefix()
db: DbSettings = DbSettings() db: DbSettings = DbSettings()
access_token: AccessToken = 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 from fastapi import FastAPI
sys.path.append(str(Path(__file__).parent.parent)) sys.path.append(str(Path(__file__).parent.parent))
from fastapi.middleware.cors import CORSMiddleware
from src.api import router from src.api import router
from src.core.settings import settings
app = FastAPI(title="Task&Coffee") app = FastAPI(title="Task&Coffee")
app.include_router(router=router) app.include_router(router=router)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_settings.local + [settings.cors_settings.production],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("src.main:app", port=8000, log_level="info", reload=True) uvicorn.run("src.main:app", port=8000, log_level="info", reload=True)

View File

@@ -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 sqlalchemy.orm import Mapped, mapped_column
from src.core.database import Base from src.core.database import Base
from src.core.settings import settings
class RefreshTokensORM(Base): class RefreshTokensORM(Base):
@@ -9,3 +12,10 @@ class RefreshTokensORM(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
token: Mapped[str] = mapped_column(String(255), nullable=False) token: Mapped[str] = mapped_column(String(255), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id")) 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 from src.repository.base import BaseRepo
class AuthRepo(BaseRepo): class AuthRepo(BaseRepo[RefreshTokensORM]):
model: type[RefreshTokensORM] = 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 import delete, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.core.interfaces import HasId ModelType = TypeVar("ModelType")
ModelType = TypeVar("ModelType", bound=HasId)
class BaseRepo(Generic[ModelType]): class BaseRepo(Generic[ModelType]):
@@ -45,12 +43,9 @@ class BaseRepo(Generic[ModelType]):
async def delete_one(self, **filter_by) -> None: async def delete_one(self, **filter_by) -> None:
await self.session.execute(delete(self.model).filter_by(**filter_by)) await self.session.execute(delete(self.model).filter_by(**filter_by))
async def update_one(self, id: int, data: dict[str, Any]) -> ModelType: async def update_one(self, data: dict[str, Any], **filter_by: Any) -> ModelType:
stmt = ( stmt = (
update(self.model) update(self.model).filter_by(**filter_by).values(data).returning(self.model)
.where(self.model.id == id)
.values(data)
.returning(self.model)
) )
result = await self.session.execute(stmt) result = await self.session.execute(stmt)
model = result.scalar_one() model = result.scalar_one()

View File

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

View File

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

View File

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

View File

@@ -19,51 +19,50 @@ class AuthService(BaseService):
await self.session.commit() await self.session.commit()
return User.model_validate(result) return User.model_validate(result)
async def login(self, username: str, password: str): async def _tokens_create(self, user_data: TokenData, fingerprint: str):
result = await self.session.user.get_one_or_none(username=username) access_token = AuthManager.create_access_token(user_data.model_dump())
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())
refresh_token = AuthManager.create_refresh_token() refresh_token = AuthManager.create_refresh_token()
await self.session.auth.create_one({"token": refresh_token, "user_id": user.id}) await self.session.auth.create_one({
await self.session.commit() "token": refresh_token,
"user_id": user_data.id,
"fingerprint": fingerprint,
})
return { return {
"access_token": access_token, "access_token": access_token,
"token_type": settings.access_token.token_type, "token_type": settings.access_token.token_type,
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
async def delete_token(self, token: str) -> None: async def login(self, username: str, password: str, fingerprint: str) -> dict:
await self.session.auth.delete_one(token=token) 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() 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) 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: if not token_record or token_record.user_id != user_data.id:
raise HTTPException(status_code=401, detail="Invalid refresh token") raise HTTPException(status_code=401, detail="Invalid refresh token")
new_access_token = AuthManager.create_access_token(user_data.model_dump()) token = await self._tokens_create(user_data, fingerprint)
new_refresh_token = AuthManager.create_refresh_token()
await self.session.auth.delete_one(token=refresh_token) 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() await self.session.commit()
return { return token
"access_token": new_access_token,
"token_type": settings.access_token.token_type, async def delete_token(self, token: str) -> None:
"refresh_token": new_refresh_token, await self.session.auth.delete_one(token=token)
} await self.session.commit()

View File

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

View File

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

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 httpx import AsyncClient
from src.core.settings import settings from src.core.settings import settings
@@ -15,14 +16,30 @@ async def test_registration(ac):
assert result.json()["is_active"] 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( result = await ac.post(
f"{settings.api.v1_login_url}/login", f"{settings.api.v1_login_url}/login",
data={ data={
"fingerprint": fingerprint,
"grant_type": "password", "grant_type": "password",
"username": "kot", "username": username,
"password": "P@ssw0rd", "password": password,
}, },
) )
assert result.status_code == 200 assert result.status_code == expected_status
assert result.json().get("access_token") 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