Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
872f420190 | ||
|
|
ff80f5bf97 |
@@ -189,6 +189,24 @@ class ProfileController extends Controller
|
|||||||
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab);
|
return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only featured artwork payload for the client-side Artworks tab.
|
||||||
|
* This deliberately does not render the profile document or track a view.
|
||||||
|
*/
|
||||||
|
public function featuredArtworks(Request $request, string $username): JsonResponse
|
||||||
|
{
|
||||||
|
$normalized = UsernamePolicy::normalize($username);
|
||||||
|
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json(['error' => 'User not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $this->loadFeaturedArtworks($user, $request->user())->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function legacyById(Request $request, int $id, ?string $username = null)
|
public function legacyById(Request $request, int $id, ?string $username = null)
|
||||||
{
|
{
|
||||||
$user = User::query()->findOrFail($id);
|
$user = User::query()->findOrFail($id);
|
||||||
@@ -989,42 +1007,9 @@ class ProfileController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Featured artworks for this user ─────────────────────────────────
|
// ── Featured artworks for this user ─────────────────────────────────
|
||||||
$featuredArtworks = collect();
|
$featuredArtworks = $initialTab === 'artworks'
|
||||||
if (Schema::hasTable('artwork_features')) {
|
? $this->loadFeaturedArtworks($user, $viewer)
|
||||||
$featuredQuery = Artwork::query()
|
: null;
|
||||||
->with([
|
|
||||||
'user:id,name,username,level,rank',
|
|
||||||
'user.profile:user_id,avatar_hash',
|
|
||||||
'group:id,name,slug,avatar_path',
|
|
||||||
'stats:artwork_id,views,downloads,favorites',
|
|
||||||
'categories' => function ($query) {
|
|
||||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
|
||||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
|
||||||
->where('artworks.user_id', $user->id)
|
|
||||||
->where('af.is_active', true)
|
|
||||||
->whereNull('af.deleted_at')
|
|
||||||
->whereNull('artworks.deleted_at')
|
|
||||||
->where('artworks.is_public', true)
|
|
||||||
->where('artworks.is_approved', true)
|
|
||||||
->whereNotNull('artworks.published_at')
|
|
||||||
->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at'])
|
|
||||||
->orderByDesc('af.featured_at')
|
|
||||||
->limit(3);
|
|
||||||
|
|
||||||
app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer);
|
|
||||||
|
|
||||||
$featuredArtworks = $featuredQuery
|
|
||||||
->get()
|
|
||||||
->map(function (Artwork $artwork) {
|
|
||||||
return (object) array_merge($this->mapArtworkCardPayload($artwork), [
|
|
||||||
'label' => $artwork->featured_label,
|
|
||||||
'featured_at' => $this->formatIsoDate($artwork->featured_slot_at),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Favourites ───────────────────────────────────────────────────────
|
// ── Favourites ───────────────────────────────────────────────────────
|
||||||
$favouriteLimit = 12;
|
$favouriteLimit = 12;
|
||||||
@@ -1397,7 +1382,7 @@ class ProfileController extends Controller
|
|||||||
'cover_image' => $profile->cover_image ?? null,
|
'cover_image' => $profile->cover_image ?? null,
|
||||||
] : null,
|
] : null,
|
||||||
'artworks' => $artworkPayload,
|
'artworks' => $artworkPayload,
|
||||||
'featuredArtworks' => $featuredArtworks->values(),
|
'featuredArtworks' => $featuredArtworks?->values(),
|
||||||
'favourites' => $favourites,
|
'favourites' => $favourites,
|
||||||
'stats' => $statsPayload,
|
'stats' => $statsPayload,
|
||||||
'socialLinks' => $socialLinks,
|
'socialLinks' => $socialLinks,
|
||||||
@@ -1440,6 +1425,50 @@ class ProfileController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canonical featured artwork representation shared by SSR and
|
||||||
|
* the lazy Artworks-tab endpoint.
|
||||||
|
*/
|
||||||
|
private function loadFeaturedArtworks(User $user, ?User $viewer): ?\Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('artwork_features')) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$featuredQuery = Artwork::query()
|
||||||
|
->with([
|
||||||
|
'user:id,name,username,level,rank',
|
||||||
|
'user.profile:user_id,avatar_hash',
|
||||||
|
'group:id,name,slug,avatar_path',
|
||||||
|
'stats:artwork_id,views,downloads,favorites',
|
||||||
|
'categories' => function ($query) {
|
||||||
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
|
||||||
|
->where('artworks.user_id', $user->id)
|
||||||
|
->where('af.is_active', true)
|
||||||
|
->whereNull('af.deleted_at')
|
||||||
|
->whereNull('artworks.deleted_at')
|
||||||
|
->where('artworks.is_public', true)
|
||||||
|
->where('artworks.is_approved', true)
|
||||||
|
->whereNotNull('artworks.published_at')
|
||||||
|
->select(['artworks.*', 'af.label as featured_label', 'af.featured_at as featured_slot_at'])
|
||||||
|
->orderByDesc('af.featured_at')
|
||||||
|
->limit(3);
|
||||||
|
|
||||||
|
app(ArtworkMaturityService::class)->applyViewerFilter($featuredQuery, $viewer);
|
||||||
|
|
||||||
|
return $featuredQuery->get()
|
||||||
|
->map(function (Artwork $artwork) {
|
||||||
|
return (object) array_merge($this->mapArtworkCardPayload($artwork), [
|
||||||
|
'label' => $artwork->featured_label,
|
||||||
|
'featured_at' => $this->formatIsoDate($artwork->featured_slot_at),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeProfileTab(mixed $tab): ?string
|
private function normalizeProfileTab(mixed $tab): ?string
|
||||||
{
|
{
|
||||||
if (! is_string($tab)) {
|
if (! is_string($tab)) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import TabStories from '../../components/profile/tabs/TabStories'
|
|||||||
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
||||||
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
||||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||||
|
import useLazyProfileFeaturedArtworks from '../../lib/useLazyProfileFeaturedArtworks'
|
||||||
|
|
||||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
|
||||||
|
|
||||||
@@ -86,6 +87,11 @@ export default function ProfileShow() {
|
|||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
||||||
|
const featuredState = useLazyProfileFeaturedArtworks({
|
||||||
|
username: user?.username || user?.name || '',
|
||||||
|
initialFeaturedArtworks: featuredArtworks,
|
||||||
|
enabled: activeTab === 'artworks',
|
||||||
|
})
|
||||||
|
|
||||||
const handleTabChange = useCallback((tab) => {
|
const handleTabChange = useCallback((tab) => {
|
||||||
if (!VALID_TABS.includes(tab)) return
|
if (!VALID_TABS.includes(tab)) return
|
||||||
@@ -195,7 +201,10 @@ export default function ProfileShow() {
|
|||||||
{activeTab === 'artworks' && (
|
{activeTab === 'artworks' && (
|
||||||
<TabArtworks
|
<TabArtworks
|
||||||
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
artworks={{ data: artworkList, next_cursor: artworkNextCursor }}
|
||||||
featuredArtworks={featuredArtworks}
|
featuredArtworks={featuredState.items}
|
||||||
|
featuredLoading={featuredState.loading}
|
||||||
|
featuredError={featuredState.error}
|
||||||
|
onRetryFeatured={featuredState.retry}
|
||||||
username={user.username || user.name}
|
username={user.username || user.name}
|
||||||
galleryUrl={galleryUrl}
|
galleryUrl={galleryUrl}
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ function FullGalleryCta({ galleryUrl, username }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TabArtworks({ artworks, featuredArtworks, username, galleryUrl }) {
|
export default function TabArtworks({ artworks, featuredArtworks, featuredLoading = false, featuredError = false, onRetryFeatured, username, galleryUrl }) {
|
||||||
const initialItems = artworks?.data ?? artworks ?? []
|
const initialItems = artworks?.data ?? artworks ?? []
|
||||||
const trendingItems = useArtworkPreview(username, 'trending')
|
const trendingItems = useArtworkPreview(username, 'trending')
|
||||||
const popularItems = useArtworkPreview(username, 'views')
|
const popularItems = useArtworkPreview(username, 'views')
|
||||||
@@ -261,6 +261,16 @@ export default function TabArtworks({ artworks, featuredArtworks, username, gall
|
|||||||
aria-labelledby="tab-artworks"
|
aria-labelledby="tab-artworks"
|
||||||
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
className="mx-auto max-w-7xl px-4 pt-2 pb-10 md:px-6"
|
||||||
>
|
>
|
||||||
|
{featuredLoading ? (
|
||||||
|
<div className="mb-8 rounded-[28px] border border-white/10 bg-white/[0.03] px-5 py-4 text-sm text-slate-400" role="status">
|
||||||
|
Loading featured artworks…
|
||||||
|
</div>
|
||||||
|
) : featuredError ? (
|
||||||
|
<div className="mb-8 rounded-[28px] border border-rose-400/20 bg-rose-400/10 px-5 py-4 text-sm text-rose-100" role="alert">
|
||||||
|
Featured artworks could not be loaded.
|
||||||
|
{onRetryFeatured ? <button type="button" className="ml-3 underline" onClick={onRetryFeatured}>Retry</button> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
|
<FeaturedShowcase featuredArtworks={featuredArtworks ?? []} />
|
||||||
|
|
||||||
<PreviewRail
|
<PreviewRail
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useLazyProfileFeaturedArtworks({ username, initialFeaturedArtworks, enabled }) {
|
||||||
|
const hasInitialValue = Array.isArray(initialFeaturedArtworks)
|
||||||
|
const [items, setItems] = useState(() => hasInitialValue ? initialFeaturedArtworks : [])
|
||||||
|
const [loaded, setLoaded] = useState(hasInitialValue)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const requestKeyRef = useRef(username)
|
||||||
|
const requestedRef = useRef(hasInitialValue)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestKeyRef.current === username) return undefined
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestKeyRef.current = username
|
||||||
|
requestedRef.current = false
|
||||||
|
setItems([])
|
||||||
|
setLoaded(false)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}, [username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialValue || requestKeyRef.current !== username) return
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
abortRef.current = null
|
||||||
|
requestedRef.current = true
|
||||||
|
setItems(initialFeaturedArtworks)
|
||||||
|
setLoaded(true)
|
||||||
|
setLoading(false)
|
||||||
|
setError(false)
|
||||||
|
}, [hasInitialValue, initialFeaturedArtworks, username])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled || hasInitialValue || requestedRef.current || !username) return
|
||||||
|
|
||||||
|
requestedRef.current = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const requestKey = requestKeyRef.current
|
||||||
|
abortRef.current = controller
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/profile/${encodeURIComponent(username)}/featured-artworks`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Featured artworks could not be loaded.')
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
if (requestKey !== requestKeyRef.current || controller.signal.aborted) return
|
||||||
|
|
||||||
|
setItems(Array.isArray(payload?.data) ? payload.data : [])
|
||||||
|
setLoaded(true)
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught?.name === 'AbortError' || requestKey !== requestKeyRef.current) return
|
||||||
|
requestedRef.current = false
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
if (requestKey === requestKeyRef.current) {
|
||||||
|
abortRef.current = null
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, hasInitialValue, username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) load()
|
||||||
|
}, [enabled, load])
|
||||||
|
|
||||||
|
useEffect(() => () => abortRef.current?.abort(), [])
|
||||||
|
|
||||||
|
return { items, loaded, loading, error, retry: load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import useLazyProfileFeaturedArtworks from './useLazyProfileFeaturedArtworks'
|
||||||
|
|
||||||
|
function Harness({ username = 'alice', initial = null, enabled = false }) {
|
||||||
|
const state = useLazyProfileFeaturedArtworks({
|
||||||
|
username,
|
||||||
|
initialFeaturedArtworks: initial,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span data-testid="count">{state.items.length}</span>
|
||||||
|
<span data-testid="loading">{String(state.loading)}</span>
|
||||||
|
<span data-testid="error">{String(state.error)}</span>
|
||||||
|
<button type="button" onClick={state.retry}>retry</button>
|
||||||
|
{state.items.map((item) => <span key={item.id}>{item.title}</span>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useLazyProfileFeaturedArtworks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ data: [{ id: 1, title: 'Featured one' }] }),
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('does not request while the posts tab is active', () => {
|
||||||
|
render(<Harness />)
|
||||||
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads once on first artworks activation and reuses the result', async () => {
|
||||||
|
const view = render(<Harness enabled />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Featured one')).toBeTruthy())
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.rerender(<Harness enabled={false} />)
|
||||||
|
view.rerender(<Harness enabled />)
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not request when SSR already supplied featured artworks', () => {
|
||||||
|
render(<Harness enabled initial={[{ id: 9, title: 'SSR feature' }]} />)
|
||||||
|
expect(screen.getByText('SSR feature')).toBeTruthy()
|
||||||
|
expect(fetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a safe retry after failure', async () => {
|
||||||
|
fetch.mockRejectedValueOnce(new Error('network'))
|
||||||
|
render(<Harness enabled />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('error').textContent).toBe('true'))
|
||||||
|
await act(async () => screen.getByRole('button', { name: 'retry' }).click())
|
||||||
|
await waitFor(() => expect(screen.getByText('Featured one')).toBeTruthy())
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a stale response after profile navigation', async () => {
|
||||||
|
let resolveAlice
|
||||||
|
fetch.mockImplementationOnce(() => new Promise((resolve) => { resolveAlice = resolve }))
|
||||||
|
const view = render(<Harness username="alice" enabled />)
|
||||||
|
|
||||||
|
view.rerender(<Harness username="bob" enabled={false} />)
|
||||||
|
await act(async () => resolveAlice({ ok: true, json: async () => ({ data: [{ id: 1, title: 'Alice feature' }] }) }))
|
||||||
|
|
||||||
|
expect(screen.queryByText('Alice feature')).toBeNull()
|
||||||
|
expect(screen.getByTestId('count').textContent).toBe('0')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -910,6 +910,7 @@ Route::middleware(['web', 'throttle:60,1'])
|
|||||||
Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks');
|
Route::get('artworks', [\App\Http\Controllers\Api\ProfileApiController::class, 'artworks'])->name('artworks');
|
||||||
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
|
Route::get('favourites', [\App\Http\Controllers\Api\ProfileApiController::class, 'favourites'])->name('favourites');
|
||||||
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
|
Route::get('stats', [\App\Http\Controllers\Api\ProfileApiController::class, 'stats'])->name('stats');
|
||||||
|
Route::get('featured-artworks', [\App\Http\Controllers\User\ProfileController::class, 'featuredArtworks'])->name('featured-artworks');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
|
// ── Link Preview (auth, throttled) ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +e
|
||||||
|
ssh_bin=ssh
|
||||||
|
remote_server=[email protected]
|
||||||
|
key=/tmp/skinbase-deploy-ssh/identities/id_ed25519
|
||||||
|
opts=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 -o IdentitiesOnly=yes)
|
||||||
|
if [[ -S /tmp/skinbase-deploy-ssh/mux-klevze_server3.klevze.si.sock ]]; then
|
||||||
|
opts+=(-o ControlPath=/tmp/skinbase-deploy-ssh/mux-klevze_server3.klevze.si.sock)
|
||||||
|
fi
|
||||||
|
if [[ -f "$key" ]]; then
|
||||||
|
opts+=(-i "$key")
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n -l'
|
||||||
|
echo EXIT:$?
|
||||||
|
echo '--- ps ---'
|
||||||
|
"$ssh_bin" "${opts[@]}" "$remote_server" "ps -ef | grep -E 'ssr|inertia|supervisor' | grep -v grep"
|
||||||
|
echo '--- status ---'
|
||||||
|
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n /usr/bin/supervisorctl status'
|
||||||
|
echo '--- restart ---'
|
||||||
|
"$ssh_bin" "${opts[@]}" "$remote_server" 'sudo -n /usr/bin/supervisorctl restart skinbase-ssr; echo rc=$?'
|
||||||
@@ -1086,6 +1086,60 @@ printf ' -> Pointed %s at %s\n' "$REMOTE_FOLDER" "$CURRENT_LINK"
|
|||||||
EOF_PUBLIC_LINK
|
EOF_PUBLIC_LINK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Supervisor listens on a root socket. The app user (skinbase) cannot talk to it,
|
||||||
|
# and artisan inertia:start-ssr fights the supervised process. Restart as the
|
||||||
|
# SSH login user with passwordless sudo supervisorctl.
|
||||||
|
restart_remote_inertia_ssr() {
|
||||||
|
[[ "$skip_ssr_restart" -eq 0 ]] || return 0
|
||||||
|
|
||||||
|
log_step "Restarting Inertia SSR via Supervisor ($ssr_supervisor_program)"
|
||||||
|
|
||||||
|
ssh_remote "$remote_server" env \
|
||||||
|
SSR_PROGRAM="$(printf '%q' "$ssr_supervisor_program")" \
|
||||||
|
bash -s <<'EOF_SSR_RESTART'
|
||||||
|
set -euo pipefail
|
||||||
|
cd /tmp >/dev/null 2>&1 || cd / >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
supervisorctl_bin="/usr/bin/supervisorctl"
|
||||||
|
if [[ ! -x "$supervisorctl_bin" ]]; then
|
||||||
|
supervisorctl_bin="$(command -v supervisorctl 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
[[ -n "$supervisorctl_bin" ]] || {
|
||||||
|
printf 'ERROR: supervisorctl not found on the remote host.\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ctl() {
|
||||||
|
sudo -n "$supervisorctl_bin" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line="$(ctl status "$SSR_PROGRAM" 2>/dev/null || true)"
|
||||||
|
if ! printf '%s\n' "$status_line" | grep -q "^${SSR_PROGRAM}[[:space:]]"; then
|
||||||
|
printf 'ERROR: Supervisor program %s not found, or sudo -n supervisorctl is not permitted for this SSH user.\n' "$SSR_PROGRAM" >&2
|
||||||
|
ctl status >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ctl restart "$SSR_PROGRAM"
|
||||||
|
|
||||||
|
status=""
|
||||||
|
i=0
|
||||||
|
while [[ "$i" -lt 10 ]]; do
|
||||||
|
status="$(ctl status "$SSR_PROGRAM" 2>/dev/null | awk '{print $2}' || true)"
|
||||||
|
if [[ "$status" == "RUNNING" ]]; then
|
||||||
|
printf ' -> Supervisor program %s is RUNNING\n' "$SSR_PROGRAM"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'ERROR: Supervisor program %s did not reach RUNNING after restart (status: %s).\n' "$SSR_PROGRAM" "${status:-unknown}" >&2
|
||||||
|
ctl status "$SSR_PROGRAM" >&2 || true
|
||||||
|
exit 1
|
||||||
|
EOF_SSR_RESTART
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--mode)
|
--mode)
|
||||||
@@ -1387,8 +1441,6 @@ ssh_remote_app_bash \
|
|||||||
COMPOSER_BIN="$(printf '%q' "$composer_bin")" \
|
COMPOSER_BIN="$(printf '%q' "$composer_bin")" \
|
||||||
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
|
RUN_REMOTE_MIGRATIONS="$run_remote_migrations" \
|
||||||
SKIP_MAINTENANCE="$skip_maintenance" \
|
SKIP_MAINTENANCE="$skip_maintenance" \
|
||||||
SKIP_SSR_RESTART="$skip_ssr_restart" \
|
|
||||||
SSR_SUPERVISOR_PROGRAM="$(printf '%q' "$ssr_supervisor_program")" \
|
|
||||||
DEPLOY_MODE="$(printf '%q' "$deploy_mode")" \
|
DEPLOY_MODE="$(printf '%q' "$deploy_mode")" \
|
||||||
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
|
RUN_MEILISEARCH_SETUP="$run_meilisearch_setup" \
|
||||||
FULL_UPGRADE_PRE_HOOK="$(printf '%q' "$full_upgrade_pre_hook")" \
|
FULL_UPGRADE_PRE_HOOK="$(printf '%q' "$full_upgrade_pre_hook")" \
|
||||||
@@ -1913,24 +1965,7 @@ purge_noncurrent_release_runtime_junk() {
|
|||||||
done < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null || true)
|
done < <(find "${REMOTE_RELEASE_ROOT}/releases" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null || true)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_ssr_supervisor_target() {
|
|
||||||
local preferred_program="${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}"
|
|
||||||
local detected_program=""
|
|
||||||
|
|
||||||
if supervisorctl status "$preferred_program" >/dev/null 2>&1; then
|
|
||||||
printf '%s' "$preferred_program"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
detected_program="$(supervisorctl status 2>/dev/null | awk 'tolower($1) ~ /(ssr|inertia)/ { print $1; exit }')"
|
|
||||||
|
|
||||||
if [[ -n "$detected_program" ]]; then
|
|
||||||
printf '%s' "$detected_program"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
prune_old_releases() {
|
prune_old_releases() {
|
||||||
local -a releases=()
|
local -a releases=()
|
||||||
@@ -2088,35 +2123,6 @@ log_step "Restarting queue workers"
|
|||||||
log_step "Restarting Horizon workers"
|
log_step "Restarting Horizon workers"
|
||||||
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
|
"$PHP_BIN" artisan horizon:terminate >/dev/null 2>&1 || true
|
||||||
|
|
||||||
if [[ "${SKIP_SSR_RESTART:-0}" -eq 0 ]]; then
|
|
||||||
log_step "Restarting Inertia SSR server"
|
|
||||||
restart_ssr_with_artisan() {
|
|
||||||
"$PHP_BIN" artisan inertia:stop-ssr >/dev/null 2>&1 || true
|
|
||||||
nohup "$PHP_BIN" artisan inertia:start-ssr >/tmp/skinbase-inertia-ssr.log 2>&1 < /dev/null &
|
|
||||||
}
|
|
||||||
|
|
||||||
if command -v supervisorctl >/dev/null 2>&1; then
|
|
||||||
ssr_supervisor_target="$(resolve_ssr_supervisor_target || true)"
|
|
||||||
|
|
||||||
if [[ -n "$ssr_supervisor_target" ]]; then
|
|
||||||
if [[ "$ssr_supervisor_target" != "${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}" ]]; then
|
|
||||||
printf ' -> Using detected Supervisor program: %s\n' "$ssr_supervisor_target"
|
|
||||||
fi
|
|
||||||
|
|
||||||
supervisorctl restart "$ssr_supervisor_target" || {
|
|
||||||
log_warn "supervisorctl restart ${ssr_supervisor_target} failed — falling back to artisan SSR restart."
|
|
||||||
restart_ssr_with_artisan || log_warn "artisan SSR restart failed — SSR server may need a manual restart."
|
|
||||||
}
|
|
||||||
else
|
|
||||||
log_warn "Supervisor program '${SSR_SUPERVISOR_PROGRAM:-skinbase-ssr}' not found — falling back to artisan SSR restart. Set SSR_SUPERVISOR_PROGRAM if the server uses a different name."
|
|
||||||
restart_ssr_with_artisan || log_warn "artisan SSR restart failed — add deploy/supervisor/skinbase-ssr.conf to /etc/supervisor/conf.d/ and run 'supervisorctl reread && supervisorctl update'."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log_warn "supervisorctl not available — falling back to artisan SSR restart."
|
|
||||||
restart_ssr_with_artisan || log_warn "artisan SSR restart failed — SSR server may need a manual restart."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then
|
if [[ "$RUN_MEILISEARCH_SETUP" -eq 1 ]]; then
|
||||||
if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then
|
if [[ -z "${MEILISEARCH_MODELS_CSV:-}" ]]; then
|
||||||
MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
|
MEILISEARCH_MODELS_CSV='App\Models\Artwork,App\Models\User,App\Models\Group,App\Models\Post,App\Models\Message'
|
||||||
@@ -2238,6 +2244,9 @@ if [[ "$remote_deploy_status" -ne 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
mark_phase_complete "remote-switch"
|
mark_phase_complete "remote-switch"
|
||||||
|
|
||||||
|
restart_remote_inertia_ssr
|
||||||
|
mark_phase_complete "ssr-restart"
|
||||||
|
|
||||||
# Remote deploy completed successfully; no need for the local DB-maintenance recovery path.
|
# Remote deploy completed successfully; no need for the local DB-maintenance recovery path.
|
||||||
db_sync_remote_maintenance=0
|
db_sync_remote_maintenance=0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia;
|
||||||
|
|
||||||
|
function profileFeaturedFixture(): array
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['username' => 'featuredprofile']);
|
||||||
|
$older = Artwork::factory()->for($user)->create([
|
||||||
|
'title' => 'Older featured artwork',
|
||||||
|
'published_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
$newer = Artwork::factory()->for($user)->create([
|
||||||
|
'title' => 'Newer featured artwork',
|
||||||
|
'published_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('artwork_features')->insert([
|
||||||
|
[
|
||||||
|
'artwork_id' => $older->id,
|
||||||
|
'label' => 'Older',
|
||||||
|
'is_active' => true,
|
||||||
|
'featured_at' => now()->subDay(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'artwork_id' => $newer->id,
|
||||||
|
'label' => 'Newer',
|
||||||
|
'is_active' => true,
|
||||||
|
'featured_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$user, $older, $newer];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does not include featured artworks in the default posts SSR payload', function () {
|
||||||
|
[$user] = profileFeaturedFixture();
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username)
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Profile/ProfileShow')
|
||||||
|
->where('initialTab', 'posts')
|
||||||
|
->where('featuredArtworks', null));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps featured artworks in the direct artworks SSR payload', function () {
|
||||||
|
[$user, $older, $newer] = profileFeaturedFixture();
|
||||||
|
|
||||||
|
$this->get('/@' . $user->username . '/artworks')
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (AssertableInertia $page) => $page
|
||||||
|
->component('Profile/ProfileShow')
|
||||||
|
->where('initialTab', 'artworks')
|
||||||
|
->where('featuredArtworks.0.id', $newer->id)
|
||||||
|
->where('featuredArtworks.1.id', $older->id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same ordered featured collection from the lazy endpoint', function () {
|
||||||
|
[$user, $older, $newer] = profileFeaturedFixture();
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/profile/' . $user->username . '/featured-artworks');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.0.id', $newer->id)
|
||||||
|
->assertJsonPath('data.1.id', $older->id)
|
||||||
|
->assertJsonPath('data.0.label', 'Newer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not track a profile view for the lazy featured endpoint', function () {
|
||||||
|
[$user] = profileFeaturedFixture();
|
||||||
|
$before = (int) ($user->profile_views_count ?? 0);
|
||||||
|
|
||||||
|
$this->getJson('/api/profile/' . $user->username . '/featured-artworks')->assertOk();
|
||||||
|
|
||||||
|
expect((int) $user->fresh()->profile_views_count)->toBe($before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found for an unknown featured-artworks profile', function () {
|
||||||
|
$this->getJson('/api/profile/missing-featured-user/featured-artworks')
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user