feat: add YouTube social link to user profiles (#114)

* feat: add YouTube social link to user profiles

- Add youtube_url column to auth_users schema
- Add DB migration for the new column
- Update updateProfile action with YouTube URL validation
- Update getUserProfile and getPublicProfile to include youtubeUrl
- Add YouTube input field to settings page
- Display YouTube icon on public profile pages

* fix: use drizzle-kit generate for youtube_url migration

Replaces manually created migration with proper drizzle-kit generated
migration that includes meta snapshot and journal entry.

---------

Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
This commit is contained in:
Anton
2026-02-24 05:38:40 +00:00
committed by GitHub
co-authored by Anton Pascal
parent c476d30303
commit 5781e98270
8 changed files with 1109 additions and 2 deletions
+1
View File
@@ -32,6 +32,7 @@ export default async function Settings() {
currentUsername={profile?.username ?? null}
currentGithubUrl={profile?.githubUrl ?? null}
currentXUrl={profile?.xUrl ?? null}
currentYoutubeUrl={profile?.youtubeUrl ?? null}
currentEmailNotifications={profile?.emailNotifications ?? true}
connectedAccounts={connectedAccounts}
/>
@@ -23,6 +23,14 @@ function XIcon({ className }: { className?: string }) {
)
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
)
}
interface PublicProfilePageProps {
profile: {
id: string
@@ -31,6 +39,7 @@ interface PublicProfilePageProps {
username: string
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
}
projects: Project[]
}
@@ -88,7 +97,7 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
<div className="space-y-1">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<p className="text-muted-foreground">@{profile.username}</p>
{(profile.githubUrl || profile.xUrl) && (
{(profile.githubUrl || profile.xUrl || profile.youtubeUrl) && (
<div className="flex items-center gap-3 pt-1">
{profile.githubUrl && (
<a
@@ -110,6 +119,16 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
<XIcon className="h-5 w-5" />
</a>
)}
{profile.youtubeUrl && (
<a
href={profile.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<YouTubeIcon className="h-5 w-5" />
</a>
)}
</div>
)}
</div>
@@ -40,6 +40,7 @@ interface SettingsPageProps {
currentUsername: string | null
currentGithubUrl: string | null
currentXUrl: string | null
currentYoutubeUrl: string | null
currentEmailNotifications: boolean
connectedAccounts: { providerId: string; accountId: string }[]
}
@@ -49,12 +50,14 @@ export function SettingsPage({
currentUsername,
currentGithubUrl,
currentXUrl,
currentYoutubeUrl,
currentEmailNotifications,
connectedAccounts,
}: SettingsPageProps) {
const [username, setUsername] = useState(currentUsername ?? '')
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
const [youtubeUrl, setYoutubeUrl] = useState(currentYoutubeUrl ?? '')
const [avatarUrl, setAvatarUrl] = useState(user.image)
const [isSavingUsername, setIsSavingUsername] = useState(false)
const [isSavingSocial, setIsSavingSocial] = useState(false)
@@ -138,6 +141,7 @@ export function SettingsPage({
const result = await updateProfile({
githubUrl: githubUrl.trim() || null,
xUrl: xUrl.trim() || null,
youtubeUrl: youtubeUrl.trim() || null,
})
setSocialMessage({
type: result.success ? 'success' : 'error',
@@ -151,7 +155,8 @@ export function SettingsPage({
const usernameChanged = username.trim() !== (currentUsername ?? '')
const socialChanged =
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
(xUrl.trim() || '') !== (currentXUrl ?? '')
(xUrl.trim() || '') !== (currentXUrl ?? '') ||
(youtubeUrl.trim() || '') !== (currentYoutubeUrl ?? '')
const handleToggleEmailNotifications = async () => {
const newValue = !emailNotifications
@@ -389,6 +394,24 @@ export function SettingsPage({
/>
</div>
<div className="space-y-2">
<label htmlFor="youtube" className="font-medium text-sm">
YouTube
</label>
<input
id="youtube"
type="url"
value={youtubeUrl}
onChange={(e) => {
setYoutubeUrl(e.target.value)
setSocialMessage(null)
}}
placeholder="https://youtube.com/@yourchannel"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingSocial}
/>
</div>
{socialMessage && (
<div
className={`rounded-md border p-3 text-sm ${
@@ -122,6 +122,7 @@ export async function getUserProfile(): Promise<{
username: string | null
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
emailNotifications: boolean
} | null> {
const session = await getSession()
@@ -132,6 +133,7 @@ export async function getUserProfile(): Promise<{
username: schema.users.username,
githubUrl: schema.users.githubUrl,
xUrl: schema.users.xUrl,
youtubeUrl: schema.users.youtubeUrl,
emailNotifications: schema.users.emailNotifications,
})
.from(schema.users)
@@ -147,6 +149,7 @@ export async function getUserProfile(): Promise<{
export async function updateProfile(data: {
githubUrl?: string | null
xUrl?: string | null
youtubeUrl?: string | null
}): Promise<{ success: boolean; error?: string }> {
const session = await getSession()
if (!session?.user) {
@@ -159,12 +162,16 @@ export async function updateProfile(data: {
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
return { success: false, error: 'Invalid X/Twitter URL' }
}
if (data.youtubeUrl && !/^https:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(data.youtubeUrl)) {
return { success: false, error: 'Invalid YouTube URL' }
}
await db
.update(schema.users)
.set({
githubUrl: data.githubUrl ?? null,
xUrl: data.xUrl ?? null,
youtubeUrl: data.youtubeUrl ?? null,
})
.where(eq(schema.users.id, session.user.id))
@@ -184,6 +191,7 @@ export async function getPublicProfile(username: string): Promise<{
username: string
githubUrl: string | null
xUrl: string | null
youtubeUrl: string | null
}
error?: string
}> {
@@ -195,6 +203,7 @@ export async function getPublicProfile(username: string): Promise<{
username: schema.users.username,
githubUrl: schema.users.githubUrl,
xUrl: schema.users.xUrl,
youtubeUrl: schema.users.youtubeUrl,
})
.from(schema.users)
.where(sql`lower(${schema.users.username}) = lower(${username})`)
+2
View File
@@ -19,6 +19,8 @@ export const users = pgTable(
githubUrl: t.text('github_url'),
/** X/Twitter profile URL */
xUrl: t.text('x_url'),
/** YouTube channel URL */
youtubeUrl: t.text('youtube_url'),
/** Whether the user wants to receive email notifications about new features and updates */
emailNotifications: t.boolean('email_notifications').notNull().default(true),
role: userRoles('role').notNull().default('user'),
@@ -0,0 +1 @@
ALTER TABLE "auth_users" ADD COLUMN "youtube_url" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1771536409437,
"tag": "20260219212649_young_dragon_man",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1771911353115,
"tag": "20260224053553_stormy_carnage",
"breakpoints": true
}
]
}