Users can now upload an avatar from Profile settings.
This commit is contained in:
@@ -81,6 +81,7 @@ class ChannelController extends Controller
|
||||
'user' => [
|
||||
'id' => $message->user->id,
|
||||
'name' => $message->user->name,
|
||||
'avatar_url' => $message->user->avatar,
|
||||
],
|
||||
]),
|
||||
'isFollowing' => $request->user()
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -30,13 +31,26 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
$validated = $request->validated();
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
if ($request->hasFile('avatar')) {
|
||||
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.')]);
|
||||
|
||||
@@ -52,6 +66,10 @@ class ProfileController extends Controller
|
||||
|
||||
Auth::logout();
|
||||
|
||||
if ($user->avatar_path) {
|
||||
Storage::disk('public')->delete($user->avatar_path);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
@@ -17,6 +17,9 @@ class ProfileUpdateRequest extends FormRequest
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ namespace App\Models;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'can_create_channel', 'suspended_at'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
#[Fillable(['name', 'email', 'password', 'avatar_path', 'is_admin', 'can_create_channel', 'suspended_at'])]
|
||||
#[Hidden(['password', 'avatar_path', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
@@ -24,6 +26,10 @@ class User extends Authenticatable
|
||||
'can_create_channel' => false,
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'avatar',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 Attribute<string|null, never>
|
||||
*/
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): ?string => $this->avatar_path ? Storage::url($this->avatar_path) : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -199,7 +199,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
|
||||
>
|
||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
||||
<AvatarImage
|
||||
src={auth.user?.avatar}
|
||||
src={auth.user?.avatar ?? undefined}
|
||||
alt={auth.user?.name}
|
||||
/>
|
||||
<AvatarFallback className="rounded-md bg-secondary text-secondary-foreground">
|
||||
|
||||
@@ -14,7 +14,7 @@ export function UserInfo({
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar as ChatAvatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -20,6 +25,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { VideoPlayer } from '@/components/video-player';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { home, login } from '@/routes';
|
||||
import { follow, unfollow } from '@/routes/channels';
|
||||
@@ -278,6 +284,8 @@ function ChatPanel({
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const getInitials = useInitials();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-col">
|
||||
<div
|
||||
@@ -294,11 +302,28 @@ function ChatPanel({
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="text-sm">
|
||||
<span className="font-medium">{message.user.name}</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
<div
|
||||
key={message.id}
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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 DeleteUser from '@/components/delete-user';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
@@ -18,11 +22,34 @@ export default function Profile({
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function previewAvatar(file: File | null) {
|
||||
setAvatarPreviewUrl((currentUrl) => {
|
||||
if (currentUrl) {
|
||||
URL.revokeObjectURL(currentUrl);
|
||||
}
|
||||
|
||||
return file ? URL.createObjectURL(file) : null;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Profile settings" />
|
||||
@@ -33,7 +60,7 @@ export default function Profile({
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Profile information"
|
||||
description="Update your name and email address"
|
||||
description="Update your name, email address, and avatar"
|
||||
/>
|
||||
|
||||
<Form
|
||||
@@ -41,10 +68,66 @@ export default function Profile({
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
resetOnSuccess={['avatar']}
|
||||
onSuccess={() => {
|
||||
setAvatarPreviewUrl(null);
|
||||
|
||||
if (avatarInputRef.current) {
|
||||
avatarInputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
encType="multipart/form-data"
|
||||
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">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export type User = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
avatar: string | null;
|
||||
email_verified_at: string | null;
|
||||
two_factor_enabled?: boolean;
|
||||
created_at: string;
|
||||
|
||||
@@ -72,5 +72,6 @@ export type ChatMessage = {
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileUpdateTest extends TestCase
|
||||
@@ -21,6 +24,22 @@ class ProfileUpdateTest extends TestCase
|
||||
$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()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
@@ -61,9 +80,67 @@ class ProfileUpdateTest extends TestCase
|
||||
$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()
|
||||
{
|
||||
$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
|
||||
->actingAs($user)
|
||||
@@ -77,6 +154,7 @@ class ProfileUpdateTest extends TestCase
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
Storage::disk('public')->assertMissing('users/demo/avatar.jpg');
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account()
|
||||
|
||||
@@ -30,7 +30,9 @@ class ViewerInteractionTest extends TestCase
|
||||
|
||||
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]);
|
||||
$broadcast = Broadcast::factory()->for($channel)->live()->create();
|
||||
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
|
||||
@@ -47,6 +49,14 @@ class ViewerInteractionTest extends TestCase
|
||||
'user_id' => $viewer->id,
|
||||
'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
|
||||
|
||||
Reference in New Issue
Block a user