Compare commits
67 Commits
42b8c3a2c9
...
front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ae1ef37c | ||
|
|
461e9a8105 | ||
|
|
cb73cd219b | ||
|
|
0d92ea9893 | ||
|
|
1a675834fe | ||
|
|
9f7e91d0a9 | ||
|
|
69b2571dd4 | ||
|
|
bfd8c04a90 | ||
|
|
0b7a1a72d3 | ||
|
|
9820437556 | ||
|
|
cad5cfa780 | ||
|
|
afb356944d | ||
|
|
166bbee1d5 | ||
|
|
c7efdb89c2 | ||
|
|
0c15be3b38 | ||
|
|
246cd54a47 | ||
|
|
17b64121db | ||
|
|
692618ea21 | ||
|
|
024fa8b366 | ||
|
|
6f2ca0a891 | ||
|
|
fd8105d30a | ||
|
|
6b241bcfe5 | ||
|
|
5e229585ef | ||
|
|
6f49d2b171 | ||
|
|
2722488a4e | ||
|
|
7768f94122 | ||
|
|
4830d2a432 | ||
|
|
19321e3ea2 | ||
|
|
ffe9a27921 | ||
|
|
53270970cb | ||
|
|
6712c189c0 | ||
|
|
b816906c5c | ||
|
|
c647f837e8 | ||
|
|
80b40ce1c0 | ||
|
|
36926c9974 | ||
|
|
be3181ca00 | ||
|
|
8ee524318c | ||
|
|
2c35b781dd | ||
|
|
31e8e355f3 | ||
|
|
23945b3487 | ||
|
|
b4f98fe6cd | ||
|
|
1f351bc32b | ||
|
|
3708a612f7 | ||
|
|
4fe2d39fba | ||
|
|
15426adc69 | ||
|
|
eee26d06b7 | ||
|
|
e0f22b6c5a | ||
|
|
4a33b8defa | ||
|
|
7978b1042d | ||
|
|
33bd88629f | ||
|
|
e41b8922e5 | ||
|
|
4211be86ff | ||
|
|
88ace64d13 | ||
|
|
9decb7591e | ||
|
|
41c64127a5 | ||
|
|
ac28f9b420 | ||
|
|
91daaf9275 | ||
|
|
64dcc77518 | ||
|
|
ddaa5b0c74 | ||
|
|
23927e5347 | ||
|
|
67a570ceac | ||
|
|
966b59ff20 | ||
|
|
b87c37b149 | ||
|
|
4e47ce80fd | ||
|
|
f4e46bef22 | ||
|
|
3dc36a2f25 | ||
|
|
408e32d05f |
16
README.md
16
README.md
@@ -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) - быстрая справка
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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)]
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
src/main.py
11
src/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
24
taskncoffee-app/.gitignore
vendored
Normal 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
76
taskncoffee-app/README.md
Normal 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 ✨**
|
||||||
22
taskncoffee-app/components.json
Normal file
22
taskncoffee-app/components.json
Normal 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": {}
|
||||||
|
}
|
||||||
259
taskncoffee-app/docs/COLOR_PALETTE.md
Normal file
259
taskncoffee-app/docs/COLOR_PALETTE.md
Normal 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
|
||||||
|
|
||||||
244
taskncoffee-app/docs/STYLES_CHEATSHEET.md
Normal file
244
taskncoffee-app/docs/STYLES_CHEATSHEET.md
Normal 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`
|
||||||
|
|
||||||
371
taskncoffee-app/docs/STYLE_GUIDE.md
Normal file
371
taskncoffee-app/docs/STYLE_GUIDE.md
Normal 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 и неоновую эстетику! 🎉
|
||||||
|
|
||||||
39
taskncoffee-app/eslint.config.js
Normal file
39
taskncoffee-app/eslint.config.js
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
taskncoffee-app/index.html
Normal file
13
taskncoffee-app/index.html
Normal 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>
|
||||||
8
taskncoffee-app/jsconfig.json
Normal file
8
taskncoffee-app/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5776
taskncoffee-app/package-lock.json
generated
Normal file
5776
taskncoffee-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
taskncoffee-app/package.json
Normal file
43
taskncoffee-app/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
taskncoffee-app/public/images/a.jpg
Normal file
BIN
taskncoffee-app/public/images/a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 399 KiB |
0
taskncoffee-app/src/App.css
Normal file
0
taskncoffee-app/src/App.css
Normal file
35
taskncoffee-app/src/App.jsx
Normal file
35
taskncoffee-app/src/App.jsx
Normal 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
|
||||||
37
taskncoffee-app/src/api/ApiSources.jsx
Normal file
37
taskncoffee-app/src/api/ApiSources.jsx
Normal 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 };
|
||||||
100
taskncoffee-app/src/api/auth.service.js
Normal file
100
taskncoffee-app/src/api/auth.service.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
23
taskncoffee-app/src/api/client.js
Normal file
23
taskncoffee-app/src/api/client.js
Normal 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;
|
||||||
42
taskncoffee-app/src/api/fingerprint.js
Normal file
42
taskncoffee-app/src/api/fingerprint.js
Normal 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');
|
||||||
|
};
|
||||||
|
|
||||||
22
taskncoffee-app/src/api/tasks.service.js
Normal file
22
taskncoffee-app/src/api/tasks.service.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
44
taskncoffee-app/src/api/users.service.js
Normal file
44
taskncoffee-app/src/api/users.service.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
taskncoffee-app/src/assets/fonts/Carry-You.ttf
Normal file
BIN
taskncoffee-app/src/assets/fonts/Carry-You.ttf
Normal file
Binary file not shown.
BIN
taskncoffee-app/src/assets/fonts/Fabulous.ttf
Normal file
BIN
taskncoffee-app/src/assets/fonts/Fabulous.ttf
Normal file
Binary file not shown.
BIN
taskncoffee-app/src/assets/fonts/Sacramento.woff2
Normal file
BIN
taskncoffee-app/src/assets/fonts/Sacramento.woff2
Normal file
Binary file not shown.
BIN
taskncoffee-app/src/assets/fonts/vibur.woff2
Normal file
BIN
taskncoffee-app/src/assets/fonts/vibur.woff2
Normal file
Binary file not shown.
178
taskncoffee-app/src/components/Calendar.jsx
Normal file
178
taskncoffee-app/src/components/Calendar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
taskncoffee-app/src/components/Card.jsx
Normal file
139
taskncoffee-app/src/components/Card.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
taskncoffee-app/src/components/buttons.jsx
Normal file
42
taskncoffee-app/src/components/buttons.jsx
Normal 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 }
|
||||||
|
}
|
||||||
46
taskncoffee-app/src/components/test.jsx
Normal file
46
taskncoffee-app/src/components/test.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
taskncoffee-app/src/components/ui/avatar.jsx
Normal file
45
taskncoffee-app/src/components/ui/avatar.jsx
Normal 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 }
|
||||||
58
taskncoffee-app/src/components/ui/button.jsx
Normal file
58
taskncoffee-app/src/components/ui/button.jsx
Normal 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 }
|
||||||
101
taskncoffee-app/src/components/ui/card.jsx
Normal file
101
taskncoffee-app/src/components/ui/card.jsx
Normal 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,
|
||||||
|
}
|
||||||
24
taskncoffee-app/src/components/ui/input.jsx
Normal file
24
taskncoffee-app/src/components/ui/input.jsx
Normal 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 }
|
||||||
21
taskncoffee-app/src/components/ui/label.jsx
Normal file
21
taskncoffee-app/src/components/ui/label.jsx
Normal 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 }
|
||||||
189
taskncoffee-app/src/components/ui/shadcn-io/menu-dock/index.jsx
Normal file
189
taskncoffee-app/src/components/ui/shadcn-io/menu-dock/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
169
taskncoffee-app/src/index.css
Normal file
169
taskncoffee-app/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
taskncoffee-app/src/layouts/AuthLayout.jsx
Normal file
13
taskncoffee-app/src/layouts/AuthLayout.jsx
Normal 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 }
|
||||||
168
taskncoffee-app/src/lib/styles.js
Normal file
168
taskncoffee-app/src/lib/styles.js
Normal 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;
|
||||||
|
|
||||||
39
taskncoffee-app/src/lib/utils.js
Normal file
39
taskncoffee-app/src/lib/utils.js
Normal 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;
|
||||||
|
}
|
||||||
11
taskncoffee-app/src/main.jsx
Normal file
11
taskncoffee-app/src/main.jsx
Normal 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>
|
||||||
|
)
|
||||||
221
taskncoffee-app/src/neon.css
Normal file
221
taskncoffee-app/src/neon.css
Normal 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));
|
||||||
|
}
|
||||||
120
taskncoffee-app/src/pages/Dashboard.jsx
Normal file
120
taskncoffee-app/src/pages/Dashboard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
123
taskncoffee-app/src/pages/Login.jsx
Normal file
123
taskncoffee-app/src/pages/Login.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
taskncoffee-app/src/pages/RootRedirect.jsx
Normal file
48
taskncoffee-app/src/pages/RootRedirect.jsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
67
taskncoffee-app/src/pages/SignUp.jsx
Normal file
67
taskncoffee-app/src/pages/SignUp.jsx
Normal 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 };
|
||||||
13
taskncoffee-app/vite.config.js
Normal file
13
taskncoffee-app/vite.config.js
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user