Adds latest episode api endpoint and some other minor modifications.

This commit is contained in:
Paul Couture 2025-07-27 16:44:50 +00:00
parent 97b018f2bc
commit 667f0acd83
30 changed files with 3007 additions and 3336 deletions

View File

@ -72,6 +72,7 @@ class ArtistController extends Controller
$artworks = Artwork::where('artist_id', $artist->id) $artworks = Artwork::where('artist_id', $artist->id)
->with('episode') ->with('episode')
->with('podcast') ->with('podcast')
->whereNotNull('approved_by')
->orderBy('artworks.created_at', 'desc') ->orderBy('artworks.created_at', 'desc')
->paginate($perPage = 92, $columns = ['*'], $pageName = 'artworks'); ->paginate($perPage = 92, $columns = ['*'], $pageName = 'artworks');
$podcasts = Podcast::where('published', true)->with('episodes')->get(); $podcasts = Podcast::where('published', true)->with('episodes')->get();

View File

@ -26,7 +26,7 @@ class PasswordResetLinkController extends Controller
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'email' => ['required', 'email'], 'email' => ['required', 'email', 'exists:users,email'],
]); ]);
// We will send the password reset link to this user. Once we have attempted // We will send the password reset link to this user. Once we have attempted

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Facades\DB;
use App\Models\Podcast; use App\Models\Podcast;
use App\Models\Artworks; use App\Models\Artworks;
use App\Models\Episode; use App\Models\Episode;
use App\Http\Resources\LatestEpisodeResource;
class PodcastController extends Controller class PodcastController extends Controller
@ -33,4 +34,26 @@ class PodcastController extends Controller
'podcasts' => $podcasts, 'podcasts' => $podcasts,
]); ]);
} }
/**
* Display the latest episode's chosen artwork for third party tools.
*
* @param $slug
* @return \Illuminate\Http\Response
*/
public function latest_artwork(Request $request, $slug)
{
$podcast = Podcast::with('latestArtwork.artist')
->where('slug', $slug)
->where('published', true)
->firstOrFail();
$art = $podcast->latestArtwork;
return new LatestEpisodeResource($podcast);
return response()->json([
'episode_number' => optional($podcast->latestEpisode)->episode_number,
'artwork' => $art,
'artist' => optional($art)->artist,
]);
}
} }

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class LatestEpisodeResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param Request $request
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$podcast = $this->resource;
$artwork = $podcast->latestArtwork;
$episode = $artwork->episode;
$artist = $artwork->artist;
$static = config('app.static_asset_url');
$full = $static . '/artworks/' . $artwork->filename;
$thumb = $static . '/thumbnails/' . $artwork->filename;
$raw = (float) $episode->episode_number;
if (fmod($raw, 1) === 0.0) {
// no fractional part
$episodeNumber = (int) $raw;
} else {
// keep one decimal place
$episodeNumber = round($raw, 1);
}
return [
'artist_name' => $artist->name,
'artist_profile' => url('/artist/' . $artist->slug),
'artist_avatar' => $artist->avatar(),
'artwork_full' => $full,
'artwork_thumb' => $thumb,
'episode_title' => $episode->title,
'episode_number' => $episodeNumber,
'episode_date' => $episode->episode_date->format('Y-m-d'),
'episode_mp3' => $episode->mp3,
'podcast_title' => $podcast->name,
'podcast_archive' => url('/podcasts/' . $podcast->slug),
];
}
}

View File

@ -33,4 +33,27 @@ class Podcast extends Model
return $this->hasManyThrough(Artist::class, Episode::class); return $this->hasManyThrough(Artist::class, Episode::class);
} }
public function latestEpisode()
{
return $this->hasOne(Episode::class)
->where('published', true)
->orderBy('episode_number', 'desc');
}
public function latestArtwork()
{
// this follows the hasOneThrough from Episode → Artwork
return $this->hasOneThrough(
Artwork::class, // final model
Episode::class, // intermediate
'podcast_id', // FK on episodes
'id', // PK on artworks
'id', // PK on podcasts
'artwork_id' // FK on episodes → artworks
)
->where('episodes.published', true)
->orderBy('episodes.episode_number', 'desc');
}
} }

View File

@ -28,6 +28,11 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
}); });
RateLimiter::for('password-reset', function (Request $request) {
$key = Str::lower($request->input('email')).'|'.$request->ip();
return Limit::perHour(5)->by($key);
});
$this->routes(function () { $this->routes(function () {
Route::middleware('api') Route::middleware('api')
->prefix('api') ->prefix('api')

2071
site/composer.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function r({state:i}){return{state:i,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=o=>o===null?0:Array.isArray(o)?o.length:typeof o!="object"?0:Object.keys(o).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows),s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.rows=e,this.updateState()},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default}; function r({state:o}){return{state:o,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows);this.rows=[];let s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.$nextTick(()=>{this.rows=e,this.updateState()})},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{["x-on:blur"]:"createTag()",["x-model"]:"newTag",["x-on:keydown"](t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},["x-on:paste"](){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default}; function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default};

View File

@ -1 +1 @@
function t({initialHeight:e}){return{init:function(){this.render()},render:function(){this.$el.scrollHeight>0&&(this.$el.style.height=e+"rem",this.$el.style.height=this.$el.scrollHeight+"px")}}}export{t as default}; function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init:function(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight:function(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize:function(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver:function(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
function c(){return{collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,init:function(){this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1})},mountAction:function(e,s=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,s)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){if(this.isLoading=!0,this.areRecordsSelected(this.getRecordsInGroupOnPage(e))){this.deselectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e));return}this.selectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e)),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let s=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])t.dataset.group===e&&s.push(t.value);return s},getRecordsOnPage:function(){let e=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(s.value);return e},selectRecords:function(e){for(let s of e)this.isRecordSelected(s)||this.selectedRecords.push(s)},deselectRecords:function(e){for(let s of e){let t=this.selectedRecords.indexOf(s);t!==-1&&this.selectedRecords.splice(t,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(s=>this.isRecordSelected(s))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]}}}export{c as default}; function n(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){if(this.isLoading=!0,this.areRecordsSelected(this.getRecordsInGroupOnPage(e))){this.deselectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e));return}this.selectRecords(await this.$wire.getGroupedSelectableTableRecordKeys(e)),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let o=s.indexOf(this.lastChecked),r=s.indexOf(t),l=[o,r].sort((i,d)=>i-d),c=[];for(let i=l[0];i<=l[1];i++)s[i].checked=t.checked,c.push(s[i].value);t.checked?this.selectRecords(c):this.deselectRecords(c)}this.lastChecked=t}}}export{n as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,9 @@
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
</div> </div>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
{{ __('IMPORTANT! To limit the number of emails the system sends and keep the server off of blocklists, give the system at least 10 minutes to send your password reset link before attempting again. Repeated attempts beyond this could block you from requesting a password reset.') }}
</div>
{{-- Session Status --}} {{-- Session Status --}}
<x-auth-session-status class="mb-4" :status="session('status')" /> <x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}"> <form method="POST" action="{{ route('password.email') }}">

View File

@ -79,7 +79,7 @@
the phone, and emails bounced. This lit a fire under my rear to complete the newest build as I was very close to complete. the phone, and emails bounced. This lit a fire under my rear to complete the newest build as I was very close to complete.
</p> </p>
<p> <p>
The latest version of the generator launched on Sunday, December 17<sup>th</sup>, 2024 to a rocky launch with "good-enough" The latest version of the generator launched on Sunday, December 17<sup>th</sup>, 2023 to a rocky launch with "good-enough"
functionality, it took a few days to stabilize the cacheing and hardware, but you are now viewing the "new hotness" or whatever functionality, it took a few days to stabilize the cacheing and hardware, but you are now viewing the "new hotness" or whatever
the kids these days call it, powered by Laravel 10 in a docker powered container that can be migrated in minutes to other locations. the kids these days call it, powered by Laravel 10 in a docker powered container that can be migrated in minutes to other locations.
It facilitates point-in-time backups, and as you will see below, makes it fairly simple for anyone to grab and entire archive of the It facilitates point-in-time backups, and as you will see below, makes it fairly simple for anyone to grab and entire archive of the
@ -108,9 +108,12 @@
<li><a class="d-block" href="http://dvorak.org/NA">Support the Show</a><span class="cate small">First and Foremost, support our boys.</span></li> <li><a class="d-block" href="http://dvorak.org/NA">Support the Show</a><span class="cate small">First and Foremost, support our boys.</span></li>
<li><a class="d-block" href="https://paypal.me/uncappedturtle">PayPal Me</a><span class="cate small">@UncappedTurtle and buy me a coffee - or twelve.</span></li> <li><a class="d-block" href="https://paypal.me/uncappedturtle">PayPal Me</a><span class="cate small">@UncappedTurtle and buy me a coffee - or twelve.</span></li>
<li><a class="d-block" href="https://venmo.com/ucturtle">Venmo</a><span class="cate small">@ucturtle if you would like.</span></li> <li><a class="d-block" href="https://venmo.com/ucturtle">Venmo</a><span class="cate small">@ucturtle if you would like.</span></li>
<li><a class="d-block" href="https://cash.app/$ucturtle">Cash App</a><span class="cate small">if you would that is your preference.</span></li> <li><a class="d-block" href="https://cash.app/$ucturtle">Cash App</a><span class="cate small">if that is your preference.</span></li>
<li><a class="d-block" href="https://getalby.com/p/pcouture">V4V: ⚡pcouture@getalby.com</a><span class="cate small">Use the lightning network.</span></li> <li><a class="d-block" href="https://getalby.com/p/pcouture">V4V: ⚡pcouture@getalby.com</a><span class="cate small">Use the lightning network.</span></li>
<li><a class="d-block" href="https://static.noagendaartgenerator.com/assets/img/btc-naart-qr.png">Crypto: <i class="ri-btc-line"></i> Old School BTC</a><span class="cate small">bc1qvkm9fpycc8q99kudqwukd8cf8xgxdrhp6a5zl8</span></li> <li><a class="d-block" href="https://static.noagendaartgenerator.com/assets/img/btc-naart-qr.png">Crypto: <i class="ri-btc-line"></i> Old School BTC</a><span class="cate small">bc1qvkm9fpycc8q99kudqwukd8cf8xgxdrhp6a5zl8</span></li>
<li><a class="d-block" href="https://www.amazon.com/hz/wishlist/ls/ZLKL3MHKH4UU?ref_=wl_share">My Amazon Wish List</a><span class="cate small">Buy Me Something</span></li>
<li>My PO Box<br><span class="cate small">Paul Couture<br>PO Box 224<br>Benton, TN 37307</span></li>
<li><a class="d-block" href="https://paulcouture.com">PaulCouture.com</a><span class="cate small">I am open for projects</span></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -26,7 +26,8 @@ Route::middleware('guest')->group(function () {
->name('password.request'); ->name('password.request');
Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email'); ->name('password.email')
->middleware('throttle:password-reset');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset'); ->name('password.reset');

View File

@ -26,6 +26,7 @@ Route::get('/artworks/{id}', [ArtworkController::class, 'show']);
Route::get('/artist/{slug}', [ArtistController::class, 'show']); Route::get('/artist/{slug}', [ArtistController::class, 'show']);
Route::get('/artists', [ArtistController::class, 'index']); Route::get('/artists', [ArtistController::class, 'index']);
Route::get('/podcasts/{slug}', [PodcastController::class, 'show']); Route::get('/podcasts/{slug}', [PodcastController::class, 'show']);
Route::get('/podcasts/{slug}/latest_artwork', [PodcastController::class, 'latest_artwork']);
Route::get('/podcast/{any}/episode/{slug}', [EpisodeController::class, 'show']); Route::get('/podcast/{any}/episode/{slug}', [EpisodeController::class, 'show']);
Route::get('/leaderboards', [PageController::class, 'leaderboards'])->name('leaderboards'); Route::get('/leaderboards', [PageController::class, 'leaderboards'])->name('leaderboards');
Route::get('/support-development', [PageController::class, 'support'])->name('support'); Route::get('/support-development', [PageController::class, 'support'])->name('support');