Update viewer page to include moderator actions, links to Twitch viewer cards

This commit is contained in:
Alex Thomassen 2023-12-22 19:44:46 +00:00
parent e45fb69b6a
commit 70fa331f46
Signed by: Alex
GPG Key ID: 10BD786B5F6FF5DE
9 changed files with 199 additions and 92 deletions

View File

@ -12,7 +12,7 @@ class ViewerController extends Controller
{
public function index(ViewerRequest $request, string $viewer)
{
$viewer = strtolower($viewer);
$viewer = strtolower(trim($viewer));
$message = Message::orderBy('timestamp', 'desc')
->where('author_id', $viewer)
->orWhere('author_login', $viewer)
@ -45,11 +45,26 @@ public function index(ViewerRequest $request, string $viewer)
return $b->timestamp <=> $a->timestamp;
});
$user = $request->user();
$moderatorActions = [];
$channelPerms = $user->channelPermissions();
$channels = $user->getTraceChannels();
$channelPermsIds = $channelPerms->pluck('channel_provider_id')->toArray();
$moderatorActions = ModeratorAction::where('target_id', $viewerId)
->orWhere('initiator_id', $viewerId)
->whereIn('channel_id', $channelPermsIds)
->orderBy('timestamp', 'desc')
->paginate(50);
return view('viewer.index', [
'id' => $viewerId,
'username' => $initialMessage->author_login,
'usernames' => $usernames,
'messages' => $messages,
'actions' => $moderatorActions,
'channels' => $channels,
]);
}
}

View File

@ -72,7 +72,7 @@ public function formatted($asHtml = false) : string
}
/**
* Get the target of the action, if any
* Get the target name of the action, if any
*
* @param bool $linkToViewerPage Returns an HTML link to the viewer page
* @return string
@ -106,6 +106,30 @@ public function targetName($linkToViewerPage = false) : ?string
return '';
}
/**
* Get the target ID of the action, if any
*
* @return string|null
*/
public function targetId() : ?string
{
if (!empty($this->target_id)) {
return $this->target_id;
}
return null;
}
/**
* Get the channel that the action was executed in
*
* @return Channel
*/
public function channel()
{
return $this->belongsTo(Channel::class, 'channel_id', 'channel_id');
}
/**
* Get the user that executed the action
*

View File

@ -0,0 +1,82 @@
@php
$showChannel = $showChannel ?? false;
@endphp
<table class="w-full table-auto">
<thead>
<tr>
<th class="px-4 py-2">Time &amp; date</th>
<th class="px-4 py-2">Command</th>
<th class="px-4 py-2">Moderator</th>
@if ($showChannel)
<th class="px-4 py-2">Channel</th>
@endif
</tr>
</thead>
<tbody>
@foreach ($actions as $action)
<tr>
<td class="px-4 py-2 border" title="{{ $action->timestamp }} UTC" data-timestamp="{{ $action->timestamp->toIso8601String() }}"></td>
@if (empty($action->targetName()))
<td class="px-4 py-2 break-all border">
<code>{{ $action->formatted() }}</code>
</td>
@else
<td class="px-4 py-2 break-all border">
<code>
{!! $action->formatted(true) !!}
</code>
</td>
@endif
<td class="px-4 py-2 border">{{ $action->user->login }}</td>
@if ($showChannel)
@php
$channel = $action->channel->username();
@endphp
<td class="px-4 py-2 border">
<a class="text-rose-400" href="{{ route('dashboard.channel', ['channel' => $channel]) }}">
{{ $channel }}
</a>
</td>
@endif
</tr>
@endforeach
</tbody>
@if ($actions->hasPages())
<tfoot>
<tr>
<td colspan="{{ $showChannel ? 4 : 3 }}" class="px-4 py-2 border">
{{ $actions->links() }}
</td>
</tr>
</tfoot>
@endif
</table>
@section('scripts')
<script>
document.addEventListener('DOMContentLoaded', () => {
const timestamps = document.querySelectorAll('[data-timestamp]');
// Get locale from browser for Intl.DateTimeFormat
const locale = navigator.language;
const formatterOpts = {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
year: 'numeric',
month: 'long',
day: 'numeric',
};
const formatter = new Intl.DateTimeFormat(locale, formatterOpts);
for (const timestamp of timestamps) {
const date = new Date(timestamp.dataset.timestamp);
timestamp.innerText = formatter.format(date);
}
});
</script>
@endsection

View File

@ -1,55 +1,18 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="mx-auto sm:px-6 lg:px-8">
<div class="bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="overflow-hidden bg-gray-800 shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-100">
<h2 class="font-semibold text-xl text-gray-200 leading-tight">
<h2 class="text-xl font-semibold leading-tight text-gray-200">
{{ $channel->username() }}
</h2>
<table class="table-auto w-full">
<thead>
<tr>
<th class="px-4 py-2">Time &amp; date</th>
<th class="px-4 py-2">Command</th>
<th class="px-4 py-2">Moderator</th>
</tr>
</thead>
<tbody>
@foreach ($actions as $action)
<tr>
<td class="border px-4 py-2" title="{{ $action->timestamp }}">{{ $action->timestamp }}
</td>
@if (empty($action->targetName()))
<td class="border px-4 py-2 break-all">
<code>{{ $action->formatted() }}</code>
</td>
@else
<td class="border px-4 py-2 break-all">
<code>
{!! $action->formatted(true) !!}
</code>
</td>
@endif
<td class="border px-4 py-2">{{ $action->user->login }}</td>
</tr>
@endforeach
</tbody>
@if ($actions->hasPages())
<tfoot>
<tr>
<td colspan="3" class="border px-4 py-2">
{{ $actions->links() }}
</td>
</tr>
</tfoot>
@endif
</table>
@include('components.moderator-actions', ['actions' => $actions])
</div>
</div>
</div>

View File

@ -1,19 +1,19 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="mx-auto sm:px-6 lg:px-8">
<div class="bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="overflow-hidden bg-gray-800 shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-100">
{{ __("Hi :user!", ['user' => Auth::user()->display_name]) }}
</div>
</div>
<div class="bg-gray-800 p-6 -mt-6 text-gray-100">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight mb-2">
<div class="p-6 -mt-6 text-gray-100 bg-gray-800">
<h2 class="mb-2 text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
{{ __('Channels') }}
</h2>

View File

@ -10,6 +10,7 @@
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://decicus-cdn.b-cdn.net/fontawesome/v6.4.2/css/all.min.css" integrity="sha512-WgpJvPsU5RMfJeB5QbEbVfyuEGX+emeIHhNIFdc2SdyXVA11IyRLkdHZZHcnbxs/tCEAQFr2YEWrqqHFRL88eQ==" crossorigin="anonymous">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@ -17,13 +18,13 @@
@yield('head')
</head>
<body class="font-sans antialiased bg-gray-900">
<div class="flex flex-col h-screen justify-between">
<div class="flex flex-col justify-between h-screen">
<livewire:layout.navigation />
<!-- Page Heading -->
@if (isset($header))
<header class="bg-gray-800 shadow">
<div class="max-w-full mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div class="max-w-full px-4 py-6 mx-auto sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@ -31,24 +32,28 @@
<div class="mt-4">
<div class="max-w-full mx-auto sm:px-6 lg:px-8">
<div class="bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="overflow-hidden bg-gray-800 shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-100">
<p>This is the initial implementation of this project. A few different things that are planned:</p>
<ul class="list-disc list-inside mt-2">
<p><i class="fas fa-list fa-1x"></i> This is the initial implementation of this project. A few different things that are planned:</p>
<ul class="mt-2 list-disc list-inside">
<li>Channels that are tracked where you have moderator access will be <a href="https://twitch.uservoice.com/forums/310213-developers/suggestions/38203849-add-endpoint-to-return-channels-where-the-user-is" class="underline text-amber-400">automatically retrieved from Twitch's API upon login</a>. At the moment I have to manually give permissions via the database, since this API is currently only "planned" by Twitch.</li>
<li>Different filtering options: Type of action (timeout, raid etc.), moderator (whodunit), user/viewer (next point).</li>
<li>See related moderation actions, for example if a specific viewer has been timed out or banned multiple times.</li>
<li>Searching by text.</li>
<li>Linking any affected viewer's to their Twitch viewer card.</li>
<li>Automatic conversion to local time. Currently time &amp; date is displayed in UTC.</li>
<li>Ability to see a viewer's chat messages at the time of action, alongside messages before/after to see context.</li>
<li>... and probably more I'll figure out as I go along.</li>
</ul>
<p class="mt-6">List of things that were planned and has been implemented in some form:</p>
<ul class="list-disc list-inside mt-2">
<li>Username history of a user - For any user that has been timed out, raided, etc. you can now click on their name (highlighted in orange/amber) and access their basic "viewer profile".</li>
<p class="mt-6"><i class="fas fa-check fa-1x"></i> List of things that were planned and has been implemented in some form:</p>
<ul class="mt-2 list-disc list-inside">
<li><s>Username history of a user</s> - For any user that has been timed out, raided, etc. you can now click on their name (highlighted in orange/amber) and access their basic "viewer profile".</li>
<li><s>See related moderation actions, for example if a specific viewer has been timed out or banned multiple times.</s> - Implemented on the viewer profile. Will display moderation actions for any channels you have access to.</li>
<li><s>Automatic conversion to local time. Currently time &amp; date is displayed in UTC.</s> - Original UTC timestamp is still available if you hover over the converted timestamp in the table.</li>
<li><s>Linking any affected viewer's to their Twitch viewer card.</s> - Viewer card will be linked for any channel you have access to.</li>
</ul>
<p class="mt-6"><i class="fas fa-bug fa-1x"></i> Known bugs:</p>
<ul class="mt-2 list-disc list-inside">
<li>Attempting to access a viewer page of someone who has no tracked chat messages will give you a 404. Trying to decide how I want to tackle those scenarios.</li>
</ul>
</div>
</div>
@ -56,25 +61,27 @@
</div>
<!-- Page Content -->
<main class="mb-auto mt-4">
<main class="mt-4 mb-auto">
{{ $slot }}
</main>
<!-- Footer -->
<footer class="bg-gray-800 shadow bottom-0 mt-4">
<div class="max-w-full mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center">
<footer class="bottom-0 mt-4 bg-gray-800 shadow">
<div class="max-w-full px-4 py-6 mx-auto sm:px-6 lg:px-8">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
<p>TraceDash &mdash; v0.0.3 [Pre-Alpha Early Access™]</p>
<p>TraceDash &mdash; v0.0.4 [Pre-Alpha Early Access™]</p>
<p>Powered by an unhealthy amount of caffeine</p>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 text-right">
<div class="text-sm text-right text-gray-500 dark:text-gray-400">
<p>Please send any feedback or bug reports to @Decicus on Discord</p>
</div>
</div>
</div>
</footer>
</div>
@yield('scripts')
</body>
</html>

View File

@ -14,15 +14,15 @@
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<body class="font-sans antialiased text-gray-900">
<div class="flex flex-col items-center min-h-screen pt-6 bg-gray-100 sm:justify-center sm:pt-0 dark:bg-gray-900">
<div>
<a href="/" wire:navigate>
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
<x-application-logo class="w-20 h-20 text-gray-500 fill-current" />
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
<div class="w-full px-6 py-4 mt-6 overflow-hidden bg-white shadow-md sm:max-w-md dark:bg-gray-800 sm:rounded-lg">
{{ $slot }}
</div>
</div>

View File

@ -1,12 +1,12 @@
<nav x-data="{ open: false }" class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700">
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100 dark:bg-gray-800 dark:border-gray-700">
<!-- Primary Navigation Menu -->
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="max-w-full px-4 mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<div class="flex items-center shrink-0">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800 dark:text-gray-200" />
<x-application-logo class="block w-auto text-gray-800 fill-current h-9 dark:text-gray-200" />
</a>
</div>
@ -23,12 +23,12 @@
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none transition ease-in-out duration-150">
<img src="{{ Auth::user()->avatar }}" class="h-6 w-6 rounded-full" alt="{{ Auth::user()->display_name }}" />
<button class="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out bg-white border border-transparent rounded-md dark:text-gray-400 dark:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
<img src="{{ Auth::user()->avatar }}" class="w-6 h-6 rounded-full" alt="{{ Auth::user()->display_name }}" />
<div class="ml-2">{{ Auth::user()->display_name }}</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<svg class="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
@ -52,9 +52,9 @@
@endif
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<div class="flex items-center -me-2 sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 text-gray-400 transition duration-150 ease-in-out rounded-md dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-900 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-500 dark:focus:text-gray-400">
<svg class="w-6 h-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@ -75,7 +75,7 @@
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-600">
<div class="px-4">
<div class="font-medium text-base text-gray-800 dark:text-gray-200">{{ Auth::user()->display_name }}</div>
<div class="text-base font-medium text-gray-800 dark:text-gray-200">{{ Auth::user()->display_name }}</div>
</div>
<div class="mt-3 space-y-1">

View File

@ -1,27 +1,43 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
{{ __('Viewer Details') }}
</h2>
</x-slot>
<div class="max-w-full mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="overflow-hidden bg-white shadow-sm dark:bg-gray-800 sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Viewer details for: {{ $username }} [{{ $id }}]
</h2>
<p class="mt-4">Seen under the following usernames:</p>
@if (!empty($messages))
<p class="mt-6">Seen under the following usernames:</p>
<ul class="mt-2 list-disc list-inside">
@foreach ($messages as $username => $message)
<li class="mt-1">
{{ $username }} <span class="text-gray-400">&mdash; Last seen with this username: {{ date('Y-m-d', strtotime($message->timestamp)) }}</span>
</li>
@endforeach
</ul>
@endif
<!-- List style dots -->
<ul class="mt-2 list-disc list-inside">
@foreach ($messages as $username => $message)
<li class="mt-1">
{{ $username }} <span class="text-gray-400">&mdash; Last seen with this username: {{ date('Y-m-d', strtotime($message->timestamp)) }}</span>
</li>
@endforeach
</ul>
@if ($actions->isNotEmpty())
<h2 class="mt-6 text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">Moderator actions</h2>
@include('components.moderator-actions', ['actions' => $actions, 'showChannel' => true])
@endif
@if ($channels->isNotEmpty())
<h2 class="mt-6 text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"><i class="fab fa-twitch fa-1x"></i> Twitch viewer cards in these channels:</h2>
<ul class="mt-2 list-disc list-inside">
@foreach ($channels as $channel)
<li class="mt-1">
<a href="https://www.twitch.tv/popout/{{ $channel->username() }}/viewercard/{{ $username }}" target="_blank" class="text-amber-400">{{ $channel->username() }}</a>
</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>