Users can now upload an avatar from Profile settings.

This commit is contained in:
2026-05-16 04:00:06 +03:30
parent f46f32aa4c
commit 61b4cf9252
13 changed files with 274 additions and 19 deletions

View File

@@ -81,6 +81,7 @@ class ChannelController extends Controller
'user' => [ 'user' => [
'id' => $message->user->id, 'id' => $message->user->id,
'name' => $message->user->name, 'name' => $message->user->name,
'avatar_url' => $message->user->avatar,
], ],
]), ]),
'isFollowing' => $request->user() 'isFollowing' => $request->user()

View File

@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -30,13 +31,26 @@ class ProfileController extends Controller
*/ */
public function update(ProfileUpdateRequest $request): RedirectResponse public function update(ProfileUpdateRequest $request): RedirectResponse
{ {
$request->user()->fill($request->validated()); $validated = $request->validated();
$user = $request->user();
if ($request->user()->isDirty('email')) { if ($request->hasFile('avatar')) {
$request->user()->email_verified_at = null; if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
} }
$request->user()->save(); $validated['avatar_path'] = $request->file('avatar')->store("users/{$user->id}/avatars", 'public');
}
unset($validated['avatar']);
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]); Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
@@ -52,6 +66,10 @@ class ProfileController extends Controller
Auth::logout(); Auth::logout();
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$user->delete(); $user->delete();
$request->session()->invalidate(); $request->session()->invalidate();

View File

@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
return $this->profileRules($this->user()->id); return [
...$this->profileRules($this->user()->id),
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
} }
} }

View File

@@ -6,15 +6,17 @@ namespace App\Models;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password', 'is_admin', 'can_create_channel', 'suspended_at'])] #[Fillable(['name', 'email', 'password', 'avatar_path', 'is_admin', 'can_create_channel', 'suspended_at'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'avatar_path', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
@@ -24,6 +26,10 @@ class User extends Authenticatable
'can_create_channel' => false, 'can_create_channel' => false,
]; ];
protected $appends = [
'avatar',
];
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
@@ -60,4 +66,12 @@ class User extends Authenticatable
{ {
return $this->can_create_channel || PlatformSetting::current()->channel_creation_open; return $this->can_create_channel || PlatformSetting::current()->channel_creation_open;
} }
/**
* @return Attribute<string|null, never>
*/
protected function avatar(): Attribute
{
return Attribute::get(fn (): ?string => $this->avatar_path ? Storage::url($this->avatar_path) : null);
}
} }

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('avatar_path');
});
}
};

View File

@@ -199,7 +199,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
> >
<Avatar className="size-8 overflow-hidden rounded-full"> <Avatar className="size-8 overflow-hidden rounded-full">
<AvatarImage <AvatarImage
src={auth.user?.avatar} src={auth.user?.avatar ?? undefined}
alt={auth.user?.name} alt={auth.user?.name}
/> />
<AvatarFallback className="rounded-md bg-secondary text-secondary-foreground"> <AvatarFallback className="rounded-md bg-secondary text-secondary-foreground">

View File

@@ -14,7 +14,7 @@ export function UserInfo({
return ( return (
<> <>
<Avatar className="h-8 w-8 overflow-hidden rounded-full"> <Avatar className="h-8 w-8 overflow-hidden rounded-full">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar ?? undefined} alt={user.name} />
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"> <AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
{getInitials(user.name)} {getInitials(user.name)}
</AvatarFallback> </AvatarFallback>

View File

@@ -10,6 +10,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import {
Avatar as ChatAvatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@@ -20,6 +25,7 @@ import {
SheetTrigger, SheetTrigger,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import { VideoPlayer } from '@/components/video-player'; import { VideoPlayer } from '@/components/video-player';
import { useInitials } from '@/hooks/use-initials';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { home, login } from '@/routes'; import { home, login } from '@/routes';
import { follow, unfollow } from '@/routes/channels'; import { follow, unfollow } from '@/routes/channels';
@@ -278,6 +284,8 @@ function ChatPanel({
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
compact?: boolean; compact?: boolean;
}) { }) {
const getInitials = useInitials();
return ( return (
<div className="flex min-h-0 w-full flex-col"> <div className="flex min-h-0 w-full flex-col">
<div <div
@@ -294,11 +302,28 @@ function ChatPanel({
</div> </div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4"> <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((message) => ( {messages.map((message) => (
<div key={message.id} className="text-sm"> <div
<span className="font-medium">{message.user.name}</span> key={message.id}
<span className="ml-2 text-muted-foreground"> className="flex min-w-0 gap-2 text-sm"
>
<ChatAvatar className="mt-0.5 size-7 rounded-md">
<AvatarImage
src={message.user.avatar_url ?? undefined}
alt={message.user.name}
className="object-cover"
/>
<AvatarFallback className="rounded-md bg-secondary text-[11px] font-semibold text-secondary-foreground">
{getInitials(message.user.name)}
</AvatarFallback>
</ChatAvatar>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{message.user.name}
</div>
<div className="break-words text-muted-foreground">
{message.body} {message.body}
</span> </div>
</div>
</div> </div>
))} ))}
{messages.length === 0 && ( {messages.length === 0 && (

View File

@@ -1,11 +1,15 @@
import { Form, Head, Link, usePage } from '@inertiajs/react'; import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ImagePlus } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/delete-user'; import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useInitials } from '@/hooks/use-initials';
import { edit } from '@/routes/profile'; import { edit } from '@/routes/profile';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
@@ -18,11 +22,34 @@ export default function Profile({
}) { }) {
const { auth } = usePage().props; const { auth } = usePage().props;
const user = auth.user; const user = auth.user;
const getInitials = useInitials();
const avatarInputRef = useRef<HTMLInputElement>(null);
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(
null,
);
useEffect(() => {
return () => {
if (avatarPreviewUrl) {
URL.revokeObjectURL(avatarPreviewUrl);
}
};
}, [avatarPreviewUrl]);
if (!user) { if (!user) {
return null; return null;
} }
function previewAvatar(file: File | null) {
setAvatarPreviewUrl((currentUrl) => {
if (currentUrl) {
URL.revokeObjectURL(currentUrl);
}
return file ? URL.createObjectURL(file) : null;
});
}
return ( return (
<> <>
<Head title="Profile settings" /> <Head title="Profile settings" />
@@ -33,7 +60,7 @@ export default function Profile({
<Heading <Heading
variant="small" variant="small"
title="Profile information" title="Profile information"
description="Update your name and email address" description="Update your name, email address, and avatar"
/> />
<Form <Form
@@ -41,10 +68,66 @@ export default function Profile({
options={{ options={{
preserveScroll: true, preserveScroll: true,
}} }}
resetOnSuccess={['avatar']}
onSuccess={() => {
setAvatarPreviewUrl(null);
if (avatarInputRef.current) {
avatarInputRef.current.value = '';
}
}}
encType="multipart/form-data"
className="space-y-6" className="space-y-6"
> >
{({ processing, errors }) => ( {({ processing, progress, errors }) => (
<> <>
<div className="flex flex-col gap-4 rounded-md border bg-card p-4 sm:flex-row sm:items-center">
<Avatar className="size-20 rounded-md">
<AvatarImage
src={
avatarPreviewUrl ??
user.avatar ??
undefined
}
alt={user.name}
className="object-cover"
/>
<AvatarFallback className="rounded-md bg-secondary text-lg font-semibold text-secondary-foreground">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 gap-2">
<Label htmlFor="avatar">Avatar</Label>
<Input
id="avatar"
ref={avatarInputRef}
name="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) =>
previewAvatar(
event.target.files?.[0] ?? null,
)
}
/>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ImagePlus className="size-3.5" />
<span>
JPG, PNG, or WebP up to 2 MB.
</span>
</div>
{progress && (
<progress
value={progress.percentage}
max="100"
className="h-1.5 w-full"
/>
)}
<InputError message={errors.avatar} />
</div>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">Name</Label>

View File

@@ -2,7 +2,7 @@ export type User = {
id: number; id: number;
name: string; name: string;
email: string; email: string;
avatar?: string; avatar: string | null;
email_verified_at: string | null; email_verified_at: string | null;
two_factor_enabled?: boolean; two_factor_enabled?: boolean;
created_at: string; created_at: string;

View File

@@ -72,5 +72,6 @@ export type ChatMessage = {
user: { user: {
id: number; id: number;
name: string; name: string;
avatar_url: string | null;
}; };
}; };

View File

@@ -4,6 +4,9 @@ namespace Tests\Feature\Settings;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; use Tests\TestCase;
class ProfileUpdateTest extends TestCase class ProfileUpdateTest extends TestCase
@@ -21,6 +24,22 @@ class ProfileUpdateTest extends TestCase
$response->assertOk(); $response->assertOk();
} }
public function test_profile_page_exposes_user_avatar_url(): void
{
$user = User::factory()->create([
'avatar_path' => 'users/demo/avatar.jpg',
]);
$this
->actingAs($user)
->get(route('profile.edit'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/profile')
->where('auth.user.avatar', '/storage/users/demo/avatar.jpg'),
);
}
public function test_profile_information_can_be_updated() public function test_profile_information_can_be_updated()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
@@ -61,9 +80,67 @@ class ProfileUpdateTest extends TestCase
$this->assertNotNull($user->refresh()->email_verified_at); $this->assertNotNull($user->refresh()->email_verified_at);
} }
public function test_user_can_update_their_avatar(): void
{
Storage::fake('public');
$user = User::factory()->create([
'avatar_path' => 'users/demo/old-avatar.jpg',
]);
Storage::disk('public')->put($user->avatar_path, 'old avatar');
$this
->actingAs($user)
->post(route('profile.update'), [
'_method' => 'PATCH',
'name' => $user->name,
'email' => $user->email,
'avatar' => UploadedFile::fake()->image('avatar.png', 400, 400),
])
->assertRedirect(route('profile.edit'))
->assertSessionHasNoErrors();
$user->refresh();
$this->assertNotSame('users/demo/old-avatar.jpg', $user->avatar_path);
$this->assertStringStartsWith("users/{$user->id}/avatars/", $user->avatar_path);
Storage::disk('public')->assertMissing('users/demo/old-avatar.jpg');
Storage::disk('public')->assertExists($user->avatar_path);
}
public function test_user_avatar_must_be_an_image(): void
{
Storage::fake('public');
$user = User::factory()->create([
'avatar_path' => 'users/demo/current-avatar.jpg',
]);
$this
->actingAs($user)
->from(route('profile.edit'))
->post(route('profile.update'), [
'_method' => 'PATCH',
'name' => $user->name,
'email' => $user->email,
'avatar' => UploadedFile::fake()->create('avatar.txt', 8, 'text/plain'),
])
->assertRedirect(route('profile.edit'))
->assertSessionHasErrors('avatar');
$this->assertSame('users/demo/current-avatar.jpg', $user->refresh()->avatar_path);
}
public function test_user_can_delete_their_account() public function test_user_can_delete_their_account()
{ {
$user = User::factory()->create(); Storage::fake('public');
$user = User::factory()->create([
'avatar_path' => 'users/demo/avatar.jpg',
]);
Storage::disk('public')->put($user->avatar_path, 'avatar');
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
@@ -77,6 +154,7 @@ class ProfileUpdateTest extends TestCase
$this->assertGuest(); $this->assertGuest();
$this->assertNull($user->fresh()); $this->assertNull($user->fresh());
Storage::disk('public')->assertMissing('users/demo/avatar.jpg');
} }
public function test_correct_password_must_be_provided_to_delete_account() public function test_correct_password_must_be_provided_to_delete_account()

View File

@@ -30,7 +30,9 @@ class ViewerInteractionTest extends TestCase
public function test_authenticated_user_can_chat_while_channel_is_live(): void public function test_authenticated_user_can_chat_while_channel_is_live(): void
{ {
$viewer = User::factory()->create(); $viewer = User::factory()->create([
'avatar_path' => 'users/demo/avatar.jpg',
]);
$channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]); $channel = Channel::factory()->create(['slug' => 'demo', 'is_live' => true]);
$broadcast = Broadcast::factory()->for($channel)->live()->create(); $broadcast = Broadcast::factory()->for($channel)->live()->create();
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save(); $channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
@@ -47,6 +49,14 @@ class ViewerInteractionTest extends TestCase
'user_id' => $viewer->id, 'user_id' => $viewer->id,
'body' => 'Hello chat', 'body' => 'Hello chat',
]); ]);
$this->actingAs($viewer)
->get(route('channels.show', $channel))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('channels/show')
->where('chatMessages.0.user.avatar_url', '/storage/users/demo/avatar.jpg'),
);
} }
public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void public function test_anonymous_users_can_watch_channel_page_but_not_chat(): void