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:
@@ -32,6 +32,7 @@ export default async function Settings() {
|
|||||||
currentUsername={profile?.username ?? null}
|
currentUsername={profile?.username ?? null}
|
||||||
currentGithubUrl={profile?.githubUrl ?? null}
|
currentGithubUrl={profile?.githubUrl ?? null}
|
||||||
currentXUrl={profile?.xUrl ?? null}
|
currentXUrl={profile?.xUrl ?? null}
|
||||||
|
currentYoutubeUrl={profile?.youtubeUrl ?? null}
|
||||||
currentEmailNotifications={profile?.emailNotifications ?? true}
|
currentEmailNotifications={profile?.emailNotifications ?? true}
|
||||||
connectedAccounts={connectedAccounts}
|
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 {
|
interface PublicProfilePageProps {
|
||||||
profile: {
|
profile: {
|
||||||
id: string
|
id: string
|
||||||
@@ -31,6 +39,7 @@ interface PublicProfilePageProps {
|
|||||||
username: string
|
username: string
|
||||||
githubUrl: string | null
|
githubUrl: string | null
|
||||||
xUrl: string | null
|
xUrl: string | null
|
||||||
|
youtubeUrl: string | null
|
||||||
}
|
}
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
}
|
}
|
||||||
@@ -88,7 +97,7 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||||
<p className="text-muted-foreground">@{profile.username}</p>
|
<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">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
{profile.githubUrl && (
|
{profile.githubUrl && (
|
||||||
<a
|
<a
|
||||||
@@ -110,6 +119,16 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
|
|||||||
<XIcon className="h-5 w-5" />
|
<XIcon className="h-5 w-5" />
|
||||||
</a>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface SettingsPageProps {
|
|||||||
currentUsername: string | null
|
currentUsername: string | null
|
||||||
currentGithubUrl: string | null
|
currentGithubUrl: string | null
|
||||||
currentXUrl: string | null
|
currentXUrl: string | null
|
||||||
|
currentYoutubeUrl: string | null
|
||||||
currentEmailNotifications: boolean
|
currentEmailNotifications: boolean
|
||||||
connectedAccounts: { providerId: string; accountId: string }[]
|
connectedAccounts: { providerId: string; accountId: string }[]
|
||||||
}
|
}
|
||||||
@@ -49,12 +50,14 @@ export function SettingsPage({
|
|||||||
currentUsername,
|
currentUsername,
|
||||||
currentGithubUrl,
|
currentGithubUrl,
|
||||||
currentXUrl,
|
currentXUrl,
|
||||||
|
currentYoutubeUrl,
|
||||||
currentEmailNotifications,
|
currentEmailNotifications,
|
||||||
connectedAccounts,
|
connectedAccounts,
|
||||||
}: SettingsPageProps) {
|
}: SettingsPageProps) {
|
||||||
const [username, setUsername] = useState(currentUsername ?? '')
|
const [username, setUsername] = useState(currentUsername ?? '')
|
||||||
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
||||||
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
||||||
|
const [youtubeUrl, setYoutubeUrl] = useState(currentYoutubeUrl ?? '')
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user.image)
|
const [avatarUrl, setAvatarUrl] = useState(user.image)
|
||||||
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
||||||
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
||||||
@@ -138,6 +141,7 @@ export function SettingsPage({
|
|||||||
const result = await updateProfile({
|
const result = await updateProfile({
|
||||||
githubUrl: githubUrl.trim() || null,
|
githubUrl: githubUrl.trim() || null,
|
||||||
xUrl: xUrl.trim() || null,
|
xUrl: xUrl.trim() || null,
|
||||||
|
youtubeUrl: youtubeUrl.trim() || null,
|
||||||
})
|
})
|
||||||
setSocialMessage({
|
setSocialMessage({
|
||||||
type: result.success ? 'success' : 'error',
|
type: result.success ? 'success' : 'error',
|
||||||
@@ -151,7 +155,8 @@ export function SettingsPage({
|
|||||||
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
||||||
const socialChanged =
|
const socialChanged =
|
||||||
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
||||||
(xUrl.trim() || '') !== (currentXUrl ?? '')
|
(xUrl.trim() || '') !== (currentXUrl ?? '') ||
|
||||||
|
(youtubeUrl.trim() || '') !== (currentYoutubeUrl ?? '')
|
||||||
|
|
||||||
const handleToggleEmailNotifications = async () => {
|
const handleToggleEmailNotifications = async () => {
|
||||||
const newValue = !emailNotifications
|
const newValue = !emailNotifications
|
||||||
@@ -389,6 +394,24 @@ export function SettingsPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{socialMessage && (
|
||||||
<div
|
<div
|
||||||
className={`rounded-md border p-3 text-sm ${
|
className={`rounded-md border p-3 text-sm ${
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export async function getUserProfile(): Promise<{
|
|||||||
username: string | null
|
username: string | null
|
||||||
githubUrl: string | null
|
githubUrl: string | null
|
||||||
xUrl: string | null
|
xUrl: string | null
|
||||||
|
youtubeUrl: string | null
|
||||||
emailNotifications: boolean
|
emailNotifications: boolean
|
||||||
} | null> {
|
} | null> {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
@@ -132,6 +133,7 @@ export async function getUserProfile(): Promise<{
|
|||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
githubUrl: schema.users.githubUrl,
|
githubUrl: schema.users.githubUrl,
|
||||||
xUrl: schema.users.xUrl,
|
xUrl: schema.users.xUrl,
|
||||||
|
youtubeUrl: schema.users.youtubeUrl,
|
||||||
emailNotifications: schema.users.emailNotifications,
|
emailNotifications: schema.users.emailNotifications,
|
||||||
})
|
})
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
@@ -147,6 +149,7 @@ export async function getUserProfile(): Promise<{
|
|||||||
export async function updateProfile(data: {
|
export async function updateProfile(data: {
|
||||||
githubUrl?: string | null
|
githubUrl?: string | null
|
||||||
xUrl?: string | null
|
xUrl?: string | null
|
||||||
|
youtubeUrl?: string | null
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -159,12 +162,16 @@ export async function updateProfile(data: {
|
|||||||
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
||||||
return { success: false, error: 'Invalid X/Twitter URL' }
|
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
|
await db
|
||||||
.update(schema.users)
|
.update(schema.users)
|
||||||
.set({
|
.set({
|
||||||
githubUrl: data.githubUrl ?? null,
|
githubUrl: data.githubUrl ?? null,
|
||||||
xUrl: data.xUrl ?? null,
|
xUrl: data.xUrl ?? null,
|
||||||
|
youtubeUrl: data.youtubeUrl ?? null,
|
||||||
})
|
})
|
||||||
.where(eq(schema.users.id, session.user.id))
|
.where(eq(schema.users.id, session.user.id))
|
||||||
|
|
||||||
@@ -184,6 +191,7 @@ export async function getPublicProfile(username: string): Promise<{
|
|||||||
username: string
|
username: string
|
||||||
githubUrl: string | null
|
githubUrl: string | null
|
||||||
xUrl: string | null
|
xUrl: string | null
|
||||||
|
youtubeUrl: string | null
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
@@ -195,6 +203,7 @@ export async function getPublicProfile(username: string): Promise<{
|
|||||||
username: schema.users.username,
|
username: schema.users.username,
|
||||||
githubUrl: schema.users.githubUrl,
|
githubUrl: schema.users.githubUrl,
|
||||||
xUrl: schema.users.xUrl,
|
xUrl: schema.users.xUrl,
|
||||||
|
youtubeUrl: schema.users.youtubeUrl,
|
||||||
})
|
})
|
||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const users = pgTable(
|
|||||||
githubUrl: t.text('github_url'),
|
githubUrl: t.text('github_url'),
|
||||||
/** X/Twitter profile URL */
|
/** X/Twitter profile URL */
|
||||||
xUrl: t.text('x_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 */
|
/** Whether the user wants to receive email notifications about new features and updates */
|
||||||
emailNotifications: t.boolean('email_notifications').notNull().default(true),
|
emailNotifications: t.boolean('email_notifications').notNull().default(true),
|
||||||
role: userRoles('role').notNull().default('user'),
|
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
@@ -29,6 +29,13 @@
|
|||||||
"when": 1771536409437,
|
"when": 1771536409437,
|
||||||
"tag": "20260219212649_young_dragon_man",
|
"tag": "20260219212649_young_dragon_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771911353115,
|
||||||
|
"tag": "20260224053553_stormy_carnage",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user