add icon for user

This commit is contained in:
jaehwang 2026-09-16 13:04:49 +09:00
parent ef08b9f433
commit 78cf43d7ff
7 changed files with 72 additions and 36 deletions

View File

@ -24,6 +24,7 @@ def user_view(user: User) -> dict:
return { return {
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"picture": user.picture_url,
"jobs_created": user.jobs_created, "jobs_created": user.jobs_created,
"job_limit": user.job_limit, "job_limit": user.job_limit,
"jobs_left": user.jobs_left, "jobs_left": user.jobs_left,
@ -33,11 +34,13 @@ def user_view(user: User) -> dict:
async def find_or_create(session: AsyncSession, account) -> User: async def find_or_create(session: AsyncSession, account) -> User:
user = await session.get(User, account.sub) user = await session.get(User, account.sub)
if user is None: if user is None:
user = User(id=account.sub, email=account.email, name=account.name) user = User(id=account.sub, email=account.email, name=account.name,
picture_url=account.picture)
session.add(user) session.add(user)
else: else:
# 이메일·이름은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다 # 이메일·이름·사진은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다
user.email, user.name = account.email, account.name user.email, user.name, user.picture_url = (account.email, account.name,
account.picture)
await session.commit() await session.commit()
return user return user

View File

@ -12,6 +12,7 @@ from utils.database import Base
USER_ID_LENGTH = 64 USER_ID_LENGTH = 64
EMAIL_LENGTH = 320 EMAIL_LENGTH = 320
PICTURE_URL_LENGTH = 512
DEFAULT_JOB_LIMIT = 3 DEFAULT_JOB_LIMIT = 3
# 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다 # 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다
@ -24,6 +25,8 @@ class User(Base):
id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True) id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True)
email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True) email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True)
name: Mapped[str] = mapped_column(String(255), default="") name: Mapped[str] = mapped_column(String(255), default="")
# 구글 프로필 사진. 토큰에만 들어오는 값이라 로그인할 때 받아 둔다
picture_url: Mapped[str] = mapped_column(String(PICTURE_URL_LENGTH), default="")
# 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다 # 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다
job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT) job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT)

View File

@ -38,10 +38,11 @@ class GoogleTokenInvalid(RuntimeError):
class GoogleAccount: class GoogleAccount:
def __init__(self, sub: str, email: str, name: str): def __init__(self, sub: str, email: str, name: str, picture: str):
self.sub = sub self.sub = sub
self.email = email self.email = email
self.name = name self.name = name
self.picture = picture
def is_enabled() -> bool: def is_enabled() -> bool:
@ -113,4 +114,5 @@ async def verify_id_token(credential: str) -> GoogleAccount:
raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다") raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다")
return GoogleAccount(sub=sub, email=str(claims.get("email") or ""), return GoogleAccount(sub=sub, email=str(claims.get("email") or ""),
name=str(claims.get("name") or "")) name=str(claims.get("name") or ""),
picture=str(claims.get("picture") or ""))

View File

@ -20,13 +20,13 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
<Ado2Logo height={20} /> <Ado2Logo height={20} />
<span className="sidebar-product">MOVING POSTER</span> <span className="sidebar-product">MOVING POSTER</span>
</Link> </Link>
<AccountBadge />
</div> </div>
<nav className="sidebar-menu"> <nav className="sidebar-menu">
<NavLink href="/" icon="video">무빙포스터</NavLink> <NavLink href="/" icon="video">무빙포스터</NavLink>
{FEATURES.styling && <NavLink href="/studio" icon="image">포스터 스타일링</NavLink>} {FEATURES.styling && <NavLink href="/studio" icon="image">포스터 스타일링</NavLink>}
{FEATURES.archive && <NavLink href="/archive" icon="folder">아카이브</NavLink>} {FEATURES.archive && <NavLink href="/archive" icon="folder">아카이브</NavLink>}
</nav> </nav>
<AccountBadge />
<div className="sidebar-foot">무빙포스터 · 내부 빌드</div> <div className="sidebar-foot">무빙포스터 · 내부 빌드</div>
</aside> </aside>
<main className="main-content"> <main className="main-content">

View File

@ -104,30 +104,48 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
} }
.sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; } .sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; }
/* 로고 아래 로그인 상태 */ /* 사이드바 맨 아래 계정 카드 */
.account-badge { .account-card {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 0.5rem; gap: 0.6rem;
margin-top: 0.6rem; margin: 0 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: var(--radius-md);
background: var(--color-bg-darker);
border: 1px solid var(--color-border-white-10);
min-width: 0; min-width: 0;
} }
.account-badge-name { .account-photo {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-xs);
color: var(--color-text-gray-400);
}
.account-badge-quota {
flex: none; flex: none;
font-size: var(--text-xs); width: 34px;
height: 34px;
border-radius: var(--radius-full);
object-fit: cover;
background: var(--color-bg-dark);
}
.account-photo--blank {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700; font-weight: 700;
color: var(--color-mint); color: var(--color-mint);
} }
.account-badge-link { .account-body { flex: 1; min-width: 0; }
.account-name {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
font-weight: 700;
}
.account-credit {
margin: 2px 0 0;
font-size: var(--text-xs);
color: var(--color-text-gray-400);
}
.account-link {
flex: none; flex: none;
padding: 0; padding: 0;
border: none; border: none;
@ -137,7 +155,7 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--color-mint); color: var(--color-mint);
} }
.account-badge-link:hover { text-decoration: underline; } .account-link:hover { text-decoration: underline; }
.sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; } .sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; }
.sidebar-item { .sidebar-item {
display: flex; display: flex;

View File

@ -1,6 +1,6 @@
"use client"; "use client";
/** 사이드바 로고 아래에 붙는 로그인 상태. 누구로 들어와 있는지와 나가는 길 */ /** 사이드바 맨 아래 계정 카드. 누구로 들어와 있는지, 남은 크레딧, 나가는 길 */
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signOut, useMe } from "@/lib/auth"; import { signOut, useMe } from "@/lib/auth";
@ -13,22 +13,31 @@ export default function AccountBadge() {
if (me === false) { if (me === false) {
return ( return (
<button type="button" className="account-badge-link" <div className="account-card">
onClick={() => router.push("/login")}> <button type="button" className="account-link" onClick={() => router.push("/login")}>
로그인 로그인
</button> </button>
</div>
); );
} }
return ( return (
<div className="account-badge"> <div className="account-card">
<span className="account-badge-name" title={me.email}>{me.name || me.email}</span> {me.picture ? (
<span className="account-badge-quota" title="남은 크레딧"> // eslint-disable-next-line @next/next/no-img-element
{me.jobs_left}/{me.job_limit} <img className="account-photo" src={me.picture} alt="" referrerPolicy="no-referrer" />
</span> ) : (
<button type="button" className="account-badge-link" <span className="account-photo account-photo--blank">
{(me.name || me.email).slice(0, 1)}
</span>
)}
<div className="account-body">
<p className="account-name" title={me.email}>{me.name || me.email}</p>
<p className="account-credit">보유 크레딧: {me.jobs_left}</p>
</div>
<button type="button" className="account-link"
onClick={() => { signOut().then(() => setMe(false)).then(() => router.push("/")); }}> onClick={() => { signOut().then(() => setMe(false)).then(() => router.push("/")); }}>
로그아웃 나가기
</button> </button>
</div> </div>
); );

View File

@ -17,6 +17,7 @@ const SCRIPT_ID = "google-identity-services";
export interface Me { export interface Me {
email: string; email: string;
name: string; name: string;
picture: string;
jobs_created: number; jobs_created: number;
job_limit: number; job_limit: number;
jobs_left: number; jobs_left: number;