updated gallery
This commit is contained in:
8
TODO.md
Normal file
8
TODO.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# TODO SKINBASE NOVA
|
||||||
|
|
||||||
|
## FORUM
|
||||||
|
|
||||||
|
- [ ] we need to add in a main search (toolbar) and a search in the forum (search bar in the forum page)
|
||||||
|
|
||||||
|
## ARTWORKS
|
||||||
|
- [ ] http://skinbase26.test/art/69601/testna-slika => we shouldnt display follow for yourself
|
||||||
@@ -81,7 +81,8 @@ class Chat
|
|||||||
|
|
||||||
echo '<div class="row well">';
|
echo '<div class="row well">';
|
||||||
if (!empty($_SESSION['web_login']['status'])) {
|
if (!empty($_SESSION['web_login']['status'])) {
|
||||||
echo '<form action="' . htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 'UTF-8') . '" method="post">';
|
echo '<form action="' . htmlspecialchars(route('community.chat'), ENT_QUOTES, 'UTF-8') . '" method="post">';
|
||||||
|
echo csrf_field();
|
||||||
echo '<div class="col-sm-10">';
|
echo '<div class="col-sm-10">';
|
||||||
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
echo '<input type="text" class="form-control" id="chat_txt" name="chat_txt" value="">';
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
|
|||||||
@@ -63,6 +63,6 @@ class TodayDownloadsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Today Downloaded Artworks';
|
$page_title = 'Today Downloaded Artworks';
|
||||||
|
|
||||||
return view('legacy::browse', ['page_title' => $page_title, 'artworks' => $paginator]);
|
return view('web.downloads.today', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,21 +35,10 @@ class NewsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$articles = $query->paginate($perPage);
|
$articles = $query->paginate($perPage);
|
||||||
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
|
|
||||||
$trending = NewsArticle::published()
|
|
||||||
->orderByDesc('views')
|
|
||||||
->limit(config('news.trending_limit', 5))
|
|
||||||
->get(['id', 'title', 'slug', 'views', 'published_at']);
|
|
||||||
|
|
||||||
$tags = NewsTag::has('articles')->orderBy('name')->get();
|
|
||||||
|
|
||||||
return view('news.index', [
|
return view('news.index', [
|
||||||
'featured' => $featured,
|
'featured' => $featured,
|
||||||
'articles' => $articles,
|
'articles' => $articles,
|
||||||
'categories' => $categories,
|
] + $this->sidebarData());
|
||||||
'trending' => $trending,
|
|
||||||
'tags' => $tags,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -67,13 +56,10 @@ class NewsController extends Controller
|
|||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
|
|
||||||
|
|
||||||
return view('news.category', [
|
return view('news.category', [
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'articles' => $articles,
|
'articles' => $articles,
|
||||||
'categories' => $categories,
|
] + $this->sidebarData());
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -91,13 +77,10 @@ class NewsController extends Controller
|
|||||||
->orderByDesc('published_at')
|
->orderByDesc('published_at')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
|
|
||||||
|
|
||||||
return view('news.tag', [
|
return view('news.tag', [
|
||||||
'tag' => $tag,
|
'tag' => $tag,
|
||||||
'articles' => $articles,
|
'articles' => $articles,
|
||||||
'categories' => $categories,
|
] + $this->sidebarData());
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -126,7 +109,7 @@ class NewsController extends Controller
|
|||||||
return view('news.show', [
|
return view('news.show', [
|
||||||
'article' => $article,
|
'article' => $article,
|
||||||
'related' => $related,
|
'related' => $related,
|
||||||
]);
|
] + $this->sidebarData());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -154,4 +137,16 @@ class NewsController extends Controller
|
|||||||
|
|
||||||
$request->session()->put($session, true);
|
$request->session()->put($session, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function sidebarData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
|
||||||
|
'trending' => NewsArticle::published()
|
||||||
|
->orderByDesc('views')
|
||||||
|
->limit(config('news.trending_limit', 5))
|
||||||
|
->get(['id', 'title', 'slug', 'views', 'published_at']),
|
||||||
|
'tags' => NewsTag::has('articles')->orderBy('name')->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ class StoryController extends Controller
|
|||||||
'page_meta_description' => 'Long-form creator stories, tutorials, interviews and project breakdowns on Skinbase.',
|
'page_meta_description' => 'Long-form creator stories, tutorials, interviews and project breakdowns on Skinbase.',
|
||||||
'page_canonical' => route('stories.index'),
|
'page_canonical' => route('stories.index'),
|
||||||
'page_robots' => 'index,follow',
|
'page_robots' => 'index,follow',
|
||||||
|
'breadcrumbs' => collect([
|
||||||
|
(object) ['name' => 'Stories', 'url' => route('stories.index')],
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ use App\Http\Controllers\Controller;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\ArtworkStats;
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
class TopAuthorsController extends Controller
|
class TopAuthorsController extends Controller
|
||||||
{
|
{
|
||||||
@@ -51,7 +49,7 @@ class TopAuthorsController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
$page_title = 'Top Authors';
|
$page_title = 'Top Creators';
|
||||||
|
|
||||||
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
|
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ class ArtworkService
|
|||||||
$q->where('af.type', $type);
|
$q->where('af.type', $type);
|
||||||
})
|
})
|
||||||
->with([
|
->with([
|
||||||
'user:id,name',
|
'user:id,name,username',
|
||||||
'categories' => function ($q) {
|
'categories' => function ($q) {
|
||||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -254,8 +254,25 @@ final class TagService
|
|||||||
|
|
||||||
public function updateUsageCount(Tag $tag): void
|
public function updateUsageCount(Tag $tag): void
|
||||||
{
|
{
|
||||||
|
$this->syncUsageCount($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate and persist the real usage_count from artwork_tag.
|
||||||
|
*
|
||||||
|
* @return array{before:int, after:int, changed:bool}
|
||||||
|
*/
|
||||||
|
public function syncUsageCount(Tag $tag): array
|
||||||
|
{
|
||||||
|
$before = (int) $tag->usage_count;
|
||||||
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
|
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
|
||||||
$tag->forceFill(['usage_count' => $count])->save();
|
$tag->forceFill(['usage_count' => $count])->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $count,
|
||||||
|
'changed' => $before !== $count,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class AvatarUrl
|
|||||||
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
|
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
|
||||||
|
|
||||||
// Always use CDN-hosted avatar files.
|
// Always use CDN-hosted avatar files.
|
||||||
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
|
//return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
|
||||||
|
return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $size);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function default(): string
|
public static function default(): string
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Companion execution guide: [docs/legacy-routes-removal-checklist.md](docs/legacy
|
|||||||
|
|
||||||
| Method | Path | Route Name | Handler / Target |
|
| Method | Path | Route Name | Handler / Target |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | /chat | legacy.chat | ChatController@index |
|
| GET | /chat | legacy.chat | 301 -> /community/chat |
|
||||||
| POST | /chat_post | legacy.chat.post | ChatController@post |
|
| POST | /chat_post | legacy.chat.post | ChatController@post |
|
||||||
| GET | /uploads/latest | uploads.latest | LatestController@index |
|
| GET | /uploads/latest | uploads.latest | LatestController@index |
|
||||||
| GET | /uploads/daily | uploads.daily | DailyUploadsController@index |
|
| GET | /uploads/daily | uploads.daily | DailyUploadsController@index |
|
||||||
|
|||||||
@@ -47,20 +47,23 @@ On repeated requests within cooldown:
|
|||||||
- No additional verification email is queued
|
- No additional verification email is queued
|
||||||
- Generic success message is returned
|
- Generic success message is returned
|
||||||
|
|
||||||
### 3) Progressive CAPTCHA (Turnstile)
|
### 3) Progressive CAPTCHA
|
||||||
|
|
||||||
Service:
|
Service:
|
||||||
|
|
||||||
- `app/Services/Security/TurnstileVerifier.php`
|
- `app/Services/Security/CaptchaVerifier.php`
|
||||||
|
- `app/Services/Security/TurnstileVerifier.php` (legacy compatibility wrapper)
|
||||||
|
|
||||||
Controller logic (`RegisteredUserController::shouldRequireTurnstile`):
|
Controller logic (`RegisteredUserController::shouldRequireCaptcha`):
|
||||||
|
|
||||||
- Requires Turnstile for suspicious IP activity (attempt threshold)
|
- Requires CAPTCHA for suspicious IP activity (attempt threshold)
|
||||||
- Also requires Turnstile when registration rate-limit state is detected
|
- Also requires CAPTCHA when registration rate-limit state is detected
|
||||||
|
- Active provider is selected through `forum_bot_protection.captcha.provider`
|
||||||
|
|
||||||
UI behavior (`resources/views/auth/register.blade.php`):
|
UI behavior (`resources/views/auth/register.blade.php`):
|
||||||
|
|
||||||
- Turnstile widget is only rendered when required
|
- Provider-specific widget is only rendered when required
|
||||||
|
- Turnstile, reCAPTCHA, and hCaptcha are supported
|
||||||
|
|
||||||
### 4) Disposable Domain Block
|
### 4) Disposable Domain Block
|
||||||
|
|
||||||
@@ -153,9 +156,10 @@ Key settings:
|
|||||||
- `monthly_email_limit`
|
- `monthly_email_limit`
|
||||||
- `generic_success_message`
|
- `generic_success_message`
|
||||||
|
|
||||||
Turnstile config:
|
Captcha provider config:
|
||||||
|
|
||||||
- `config/services.php` under `turnstile`
|
- `config/services.php` under `turnstile`, `recaptcha`, and `hcaptcha`
|
||||||
|
- `config/forum_bot_protection.php` under `captcha`
|
||||||
|
|
||||||
Environment examples:
|
Environment examples:
|
||||||
|
|
||||||
@@ -189,7 +193,7 @@ Covered scenarios:
|
|||||||
- Cooldown suppresses extra sends
|
- Cooldown suppresses extra sends
|
||||||
- Disposable domains blocked
|
- Disposable domains blocked
|
||||||
- Quota exceeded blocks send and keeps generic success UX
|
- Quota exceeded blocks send and keeps generic success UX
|
||||||
- Turnstile required on abuse/rate-limit state
|
- CAPTCHA required on abuse/rate-limit state
|
||||||
- Tokens hashed, expire, and are one-time
|
- Tokens hashed, expire, and are one-time
|
||||||
- Responses avoid account enumeration
|
- Responses avoid account enumeration
|
||||||
|
|
||||||
@@ -199,4 +203,7 @@ Covered scenarios:
|
|||||||
- Ensure queue workers process the `mail` queue.
|
- Ensure queue workers process the `mail` queue.
|
||||||
- Monitor `email_send_events` for blocked/sent patterns.
|
- Monitor `email_send_events` for blocked/sent patterns.
|
||||||
- Set `REGISTRATION_MONTHLY_EMAIL_LIMIT` based on provider quota.
|
- Set `REGISTRATION_MONTHLY_EMAIL_LIMIT` based on provider quota.
|
||||||
- Configure `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` in production.
|
- Configure the active CAPTCHA provider keys in production:
|
||||||
|
- Turnstile: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`
|
||||||
|
- reCAPTCHA: `RECAPTCHA_ENABLED`, `RECAPTCHA_SITE_KEY`, `RECAPTCHA_SECRET_KEY`
|
||||||
|
- hCaptcha: `HCAPTCHA_ENABLED`, `HCAPTCHA_SITE_KEY`, `HCAPTCHA_SECRET_KEY`
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import React, { useState, useCallback } from 'react'
|
|||||||
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||||
|
import TurnstileField from '../../components/security/TurnstileField'
|
||||||
|
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
|
||||||
|
|
||||||
export default function ForumEditPost({ post, thread, csrfToken, errors = {} }) {
|
export default function ForumEditPost({ post, thread, csrfToken, errors = {}, captcha = {} }) {
|
||||||
const [content, setContent] = useState(post?.content ?? '')
|
const [content, setContent] = useState(post?.content ?? '')
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
@@ -18,6 +21,10 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
|
|||||||
if (submitting) return
|
if (submitting) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
// Let the form submit normally for PRG
|
// Let the form submit normally for PRG
|
||||||
|
populateBotFingerprint(e.currentTarget).finally(() => {
|
||||||
|
e.currentTarget.submit()
|
||||||
|
})
|
||||||
|
e.preventDefault()
|
||||||
}, [submitting])
|
}, [submitting])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +46,20 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="_token" value={csrfToken} />
|
<input type="hidden" name="_token" value={csrfToken} />
|
||||||
<input type="hidden" name="_method" value="PUT" />
|
<input type="hidden" name="_method" value="PUT" />
|
||||||
|
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
|
||||||
|
<input type="hidden" name="_bot_fingerprint" value="" />
|
||||||
|
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
|
||||||
|
|
||||||
|
{errors.bot ? (
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||||
|
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{errors.captcha ? (
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||||
|
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Rich text editor */}
|
{/* Rich text editor */}
|
||||||
<div>
|
<div>
|
||||||
@@ -56,6 +77,16 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
|
|||||||
<input type="hidden" name="content" value={content} />
|
<input type="hidden" name="content" value={content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{captcha.siteKey ? (
|
||||||
|
<TurnstileField
|
||||||
|
provider={captcha.provider}
|
||||||
|
siteKey={captcha.siteKey}
|
||||||
|
scriptUrl={captcha.scriptUrl}
|
||||||
|
onToken={setCaptchaToken}
|
||||||
|
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import Breadcrumbs from '../../components/forum/Breadcrumbs'
|
|||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
import TextInput from '../../components/ui/TextInput'
|
import TextInput from '../../components/ui/TextInput'
|
||||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||||
|
import TurnstileField from '../../components/security/TurnstileField'
|
||||||
|
import { populateBotFingerprint } from '../../lib/security/botFingerprint'
|
||||||
|
|
||||||
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {} }) {
|
export default function ForumNewThread({ category, csrfToken, errors = {}, oldValues = {}, captcha = {} }) {
|
||||||
const [title, setTitle] = useState(oldValues.title ?? '')
|
const [title, setTitle] = useState(oldValues.title ?? '')
|
||||||
const [content, setContent] = useState(oldValues.content ?? '')
|
const [content, setContent] = useState(oldValues.content ?? '')
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const slug = category?.slug
|
const slug = category?.slug
|
||||||
@@ -25,6 +28,7 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
|
||||||
// Standard form submission to keep server-side validation + redirect
|
// Standard form submission to keep server-side validation + redirect
|
||||||
|
await populateBotFingerprint(e.currentTarget)
|
||||||
e.target.submit()
|
e.target.submit()
|
||||||
}, [submitting])
|
}, [submitting])
|
||||||
|
|
||||||
@@ -48,6 +52,20 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
|
|||||||
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
className="space-y-5 rounded-2xl border border-white/[0.06] bg-nova-800/50 p-6 backdrop-blur"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="_token" value={csrfToken} />
|
<input type="hidden" name="_token" value={csrfToken} />
|
||||||
|
<input type="text" name="homepage_url" defaultValue="" autoComplete="off" className="hidden" aria-hidden="true" tabIndex={-1} />
|
||||||
|
<input type="hidden" name="_bot_fingerprint" value="" />
|
||||||
|
<input type="hidden" name={captcha.inputName || 'cf-turnstile-response'} value={captchaToken} />
|
||||||
|
|
||||||
|
{errors.bot ? (
|
||||||
|
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||||
|
{Array.isArray(errors.bot) ? errors.bot[0] : errors.bot}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{errors.captcha ? (
|
||||||
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||||
|
{Array.isArray(errors.captcha) ? errors.captcha[0] : errors.captcha}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Title"
|
label="Title"
|
||||||
@@ -76,6 +94,16 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
|
|||||||
<input type="hidden" name="content" value={content} />
|
<input type="hidden" name="content" value={content} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{captcha.siteKey ? (
|
||||||
|
<TurnstileField
|
||||||
|
provider={captcha.provider}
|
||||||
|
siteKey={captcha.siteKey}
|
||||||
|
scriptUrl={captcha.scriptUrl}
|
||||||
|
onToken={setCaptchaToken}
|
||||||
|
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
<a href={`/forum/${slug}`} className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function ForumThread({
|
|||||||
canModerate = false,
|
canModerate = false,
|
||||||
csrfToken = '',
|
csrfToken = '',
|
||||||
status = null,
|
status = null,
|
||||||
|
captcha = {},
|
||||||
}) {
|
}) {
|
||||||
const [currentSort, setCurrentSort] = useState(sort)
|
const [currentSort, setCurrentSort] = useState(sort)
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ export default function ForumThread({
|
|||||||
prefill={replyPrefill}
|
prefill={replyPrefill}
|
||||||
quotedAuthor={quotedPost?.user?.name}
|
quotedAuthor={quotedPost?.user?.name}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
|
captcha={captcha}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,55 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
|
||||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
|
||||||
|
|
||||||
function FreshCard({ item }) {
|
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
|
||||||
{/* Gloss sheen */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={item.thumb || FALLBACK}
|
|
||||||
alt={item.title}
|
|
||||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Top-right View badge */}
|
|
||||||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
|
||||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
|
||||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
|
||||||
<img
|
|
||||||
src={item.author_avatar || AVATAR_FALLBACK}
|
|
||||||
alt={item.author}
|
|
||||||
className="w-6 h-6 rounded-full object-cover shrink-0"
|
|
||||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{item.author}</span>
|
|
||||||
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="sr-only">{item.title} by {item.author}</span>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeFresh({ items }) {
|
export default function HomeFresh({ items }) {
|
||||||
if (!Array.isArray(items) || items.length === 0) return null
|
if (!Array.isArray(items) || items.length === 0) return null
|
||||||
@@ -63,11 +13,10 @@ export default function HomeFresh({ items }) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5">
|
<ArtworkGalleryGrid
|
||||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
items={items.slice(0, 8)}
|
||||||
<FreshCard key={item.id} item={item} />
|
showStats={false}
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
|
||||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
|
||||||
|
|
||||||
function ArtCard({ item }) {
|
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
|
||||||
{/* Gloss sheen */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={item.thumb || FALLBACK}
|
|
||||||
alt={item.title}
|
|
||||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Top-right View badge */}
|
|
||||||
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
|
||||||
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
|
||||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
|
||||||
<img
|
|
||||||
src={item.author_avatar || AVATAR_FALLBACK}
|
|
||||||
alt={item.author}
|
|
||||||
className="w-6 h-6 rounded-full object-cover shrink-0"
|
|
||||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{item.author}</span>
|
|
||||||
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="sr-only">{item.title} by {item.author}</span>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeTrending({ items }) {
|
export default function HomeTrending({ items }) {
|
||||||
if (!Array.isArray(items) || items.length === 0) return null
|
if (!Array.isArray(items) || items.length === 0) return null
|
||||||
@@ -65,11 +15,10 @@ export default function HomeTrending({ items }) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
|
<ArtworkGalleryGrid
|
||||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
items={items.slice(0, 8)}
|
||||||
<ArtCard key={item.id} item={item} />
|
className="xl:grid-cols-4"
|
||||||
))}
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import ArtworkGalleryGrid from '../../components/artwork/ArtworkGalleryGrid'
|
||||||
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
|
||||||
const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp'
|
|
||||||
|
|
||||||
function ArtCard({ item }) {
|
|
||||||
const username = item.author_username ? `@${item.author_username}` : null
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
|
||||||
<img
|
|
||||||
src={item.thumb || FALLBACK}
|
|
||||||
alt={item.title}
|
|
||||||
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100">
|
|
||||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
|
||||||
<img
|
|
||||||
src={item.author_avatar || AVATAR_FALLBACK}
|
|
||||||
alt={item.author}
|
|
||||||
className="w-5 h-5 rounded-full object-cover shrink-0"
|
|
||||||
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{item.author}</span>
|
|
||||||
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="sr-only">{item.title} by {item.author}</span>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Personalized trending: artworks matching user's top tags, sorted by trending score.
|
* Personalized trending: artworks matching user's top tags, sorted by trending score.
|
||||||
@@ -60,11 +20,7 @@ export default function HomeTrendingForYou({ items, preferences }) {
|
|||||||
See all →
|
See all →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
<ArtworkGalleryGrid items={items.slice(0, 8)} compact />
|
||||||
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
|
|
||||||
<ArtCard key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function Topbar({ user = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
|
<a href="/community/activity" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Community</a>
|
||||||
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
|
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState, useRef, useCallback } from 'react'
|
import React, { useState, useRef, useCallback } from 'react'
|
||||||
import Button from '../ui/Button'
|
import Button from '../ui/Button'
|
||||||
import RichTextEditor from './RichTextEditor'
|
import RichTextEditor from './RichTextEditor'
|
||||||
|
import TurnstileField from '../security/TurnstileField'
|
||||||
|
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
|
||||||
|
|
||||||
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken }) {
|
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken, captcha = {} }) {
|
||||||
const [content, setContent] = useState(prefill)
|
const [content, setContent] = useState(prefill)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const formRef = useRef(null)
|
const formRef = useRef(null)
|
||||||
@@ -16,16 +19,24 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const fingerprint = await buildBotFingerprint()
|
||||||
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
|
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-TOKEN': csrfToken,
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
'X-Bot-Fingerprint': fingerprint,
|
||||||
|
'X-Captcha-Token': captchaToken,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
},
|
},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ content: content.trim() }),
|
body: JSON.stringify({
|
||||||
|
content: content.trim(),
|
||||||
|
homepage_url: '',
|
||||||
|
_bot_fingerprint: fingerprint,
|
||||||
|
[captcha.inputName || 'cf-turnstile-response']: captchaToken,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -33,9 +44,10 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else if (res.status === 422) {
|
} else if (res.status === 422) {
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setError(json.errors?.content?.[0] ?? 'Validation error.')
|
setError(json.errors?.content?.[0] ?? json.errors?.bot?.[0] ?? json.message ?? 'Validation error.')
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to post reply. Please try again.')
|
const json = await res.json().catch(() => ({}))
|
||||||
|
setError(json?.errors?.bot?.[0] ?? json?.message ?? 'Failed to post reply. Please try again.')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error. Please try again.')
|
setError('Network error. Please try again.')
|
||||||
@@ -66,6 +78,16 @@ export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null,
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
|
{captcha.siteKey ? (
|
||||||
|
<TurnstileField
|
||||||
|
provider={captcha.provider}
|
||||||
|
siteKey={captcha.siteKey}
|
||||||
|
scriptUrl={captcha.scriptUrl}
|
||||||
|
onToken={setCaptchaToken}
|
||||||
|
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -157,23 +157,3 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nb-react-drag-zone {
|
|
||||||
position: absolute;
|
|
||||||
left: 48px;
|
|
||||||
right: 48px;
|
|
||||||
bottom: 0;
|
|
||||||
height: 12px;
|
|
||||||
z-index: 1;
|
|
||||||
cursor: grab;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nb-react-drag-zone:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) and (pointer: coarse) {
|
|
||||||
.nb-react-drag-zone {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ export default function CategoryPillCarousel({
|
|||||||
}) {
|
}) {
|
||||||
const viewportRef = useRef(null);
|
const viewportRef = useRef(null);
|
||||||
const stripRef = useRef(null);
|
const stripRef = useRef(null);
|
||||||
const dragZoneRef = useRef(null);
|
|
||||||
const animationRef = useRef(0);
|
const animationRef = useRef(0);
|
||||||
const suppressClickRef = useRef(false);
|
const suppressClickRef = useRef(false);
|
||||||
const dragStateRef = useRef({
|
const dragStateRef = useRef({
|
||||||
active: false,
|
active: false,
|
||||||
started: false,
|
started: false,
|
||||||
|
captured: false,
|
||||||
pointerId: null,
|
pointerId: null,
|
||||||
pointerType: 'mouse',
|
pointerType: 'mouse',
|
||||||
startX: 0,
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
startOffset: 0,
|
startOffset: 0,
|
||||||
|
startedOnLink: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
@@ -157,13 +159,10 @@ export default function CategoryPillCarousel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const strip = stripRef.current;
|
const strip = stripRef.current;
|
||||||
const dragZone = dragZoneRef.current;
|
if (!strip) return;
|
||||||
if (!strip || !dragZone) return;
|
|
||||||
|
|
||||||
const onPointerDown = (event) => {
|
const onPointerDown = (event) => {
|
||||||
const isMouse = event.pointerType === 'mouse';
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
const fromDragZone = event.currentTarget === dragZone;
|
|
||||||
if (isMouse && !fromDragZone) return;
|
|
||||||
|
|
||||||
if (animationRef.current) {
|
if (animationRef.current) {
|
||||||
cancelAnimationFrame(animationRef.current);
|
cancelAnimationFrame(animationRef.current);
|
||||||
@@ -172,16 +171,15 @@ export default function CategoryPillCarousel({
|
|||||||
|
|
||||||
dragStateRef.current.active = true;
|
dragStateRef.current.active = true;
|
||||||
dragStateRef.current.started = false;
|
dragStateRef.current.started = false;
|
||||||
|
dragStateRef.current.captured = false;
|
||||||
dragStateRef.current.pointerId = event.pointerId;
|
dragStateRef.current.pointerId = event.pointerId;
|
||||||
dragStateRef.current.pointerType = event.pointerType || 'mouse';
|
dragStateRef.current.pointerType = event.pointerType || 'mouse';
|
||||||
dragStateRef.current.startX = event.clientX;
|
dragStateRef.current.startX = event.clientX;
|
||||||
|
dragStateRef.current.startY = event.clientY;
|
||||||
dragStateRef.current.startOffset = offset;
|
dragStateRef.current.startOffset = offset;
|
||||||
|
dragStateRef.current.startedOnLink = !!event.target.closest('.nb-react-pill');
|
||||||
|
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
|
|
||||||
if (strip.setPointerCapture) {
|
|
||||||
try { strip.setPointerCapture(event.pointerId); } catch (_) { /* no-op */ }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerMove = (event) => {
|
const onPointerMove = (event) => {
|
||||||
@@ -189,13 +187,24 @@ export default function CategoryPillCarousel({
|
|||||||
if (!state.active || state.pointerId !== event.pointerId) return;
|
if (!state.active || state.pointerId !== event.pointerId) return;
|
||||||
|
|
||||||
const dx = event.clientX - state.startX;
|
const dx = event.clientX - state.startX;
|
||||||
const threshold = state.pointerType === 'touch' ? 12 : 8;
|
const dy = event.clientY - state.startY;
|
||||||
|
const threshold = state.pointerType === 'touch'
|
||||||
|
? 12
|
||||||
|
: (state.startedOnLink ? 24 : 12);
|
||||||
if (!state.started) {
|
if (!state.started) {
|
||||||
if (Math.abs(dx) <= threshold) {
|
if (Math.abs(dx) <= threshold || Math.abs(dx) <= Math.abs(dy)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.started = true;
|
state.started = true;
|
||||||
|
if (!state.captured && strip.setPointerCapture) {
|
||||||
|
try {
|
||||||
|
strip.setPointerCapture(event.pointerId);
|
||||||
|
state.captured = true;
|
||||||
|
} catch (_) {
|
||||||
|
state.captured = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +222,14 @@ export default function CategoryPillCarousel({
|
|||||||
suppressClickRef.current = state.started;
|
suppressClickRef.current = state.started;
|
||||||
state.active = false;
|
state.active = false;
|
||||||
state.started = false;
|
state.started = false;
|
||||||
|
state.startedOnLink = false;
|
||||||
state.pointerId = null;
|
state.pointerId = null;
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
|
|
||||||
if (strip.releasePointerCapture) {
|
if (state.captured && strip.releasePointerCapture) {
|
||||||
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
|
try { strip.releasePointerCapture(event.pointerId); } catch (_) { /* no-op */ }
|
||||||
}
|
}
|
||||||
|
state.captured = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickCapture = (event) => {
|
const onClickCapture = (event) => {
|
||||||
@@ -234,22 +245,12 @@ export default function CategoryPillCarousel({
|
|||||||
strip.addEventListener('pointercancel', onPointerUpOrCancel);
|
strip.addEventListener('pointercancel', onPointerUpOrCancel);
|
||||||
strip.addEventListener('click', onClickCapture, true);
|
strip.addEventListener('click', onClickCapture, true);
|
||||||
|
|
||||||
dragZone.addEventListener('pointerdown', onPointerDown);
|
|
||||||
dragZone.addEventListener('pointermove', onPointerMove);
|
|
||||||
dragZone.addEventListener('pointerup', onPointerUpOrCancel);
|
|
||||||
dragZone.addEventListener('pointercancel', onPointerUpOrCancel);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
strip.removeEventListener('pointerdown', onPointerDown);
|
strip.removeEventListener('pointerdown', onPointerDown);
|
||||||
strip.removeEventListener('pointermove', onPointerMove);
|
strip.removeEventListener('pointermove', onPointerMove);
|
||||||
strip.removeEventListener('pointerup', onPointerUpOrCancel);
|
strip.removeEventListener('pointerup', onPointerUpOrCancel);
|
||||||
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
|
strip.removeEventListener('pointercancel', onPointerUpOrCancel);
|
||||||
strip.removeEventListener('click', onClickCapture, true);
|
strip.removeEventListener('click', onClickCapture, true);
|
||||||
|
|
||||||
dragZone.removeEventListener('pointerdown', onPointerDown);
|
|
||||||
dragZone.removeEventListener('pointermove', onPointerMove);
|
|
||||||
dragZone.removeEventListener('pointerup', onPointerUpOrCancel);
|
|
||||||
dragZone.removeEventListener('pointercancel', onPointerUpOrCancel);
|
|
||||||
};
|
};
|
||||||
}, [moveTo, offset]);
|
}, [moveTo, offset]);
|
||||||
|
|
||||||
@@ -307,12 +308,6 @@ export default function CategoryPillCarousel({
|
|||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
|
<svg viewBox="0 0 20 20" fill="currentColor" className="w-[18px] h-[18px]" aria-hidden="true"><path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
|
||||||
ref={dragZoneRef}
|
|
||||||
className="nb-react-drag-zone"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useState, useEffect, useRef, useCallback, memo,
|
useState, useEffect, useRef, useCallback, memo,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import ArtworkCard from './ArtworkCard';
|
import ArtworkGallery from '../artwork/ArtworkGallery';
|
||||||
import './MasonryGallery.css';
|
import './MasonryGallery.css';
|
||||||
|
|
||||||
// ── Masonry helpers ────────────────────────────────────────────────────────
|
// ── Masonry helpers ────────────────────────────────────────────────────────
|
||||||
@@ -132,6 +132,8 @@ function mapRankApiArtwork(item) {
|
|||||||
uname: item.author?.name ?? '',
|
uname: item.author?.name ?? '',
|
||||||
username: item.author?.username ?? item.author?.name ?? '',
|
username: item.author?.username ?? item.author?.name ?? '',
|
||||||
avatar_url: item.author?.avatar_url ?? null,
|
avatar_url: item.author?.avatar_url ?? null,
|
||||||
|
content_type_name: item.category?.content_type_name ?? item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||||
|
content_type_slug: item.category?.content_type_slug ?? item.category?.content_type ?? '',
|
||||||
category_name: item.category?.name ?? '',
|
category_name: item.category?.name ?? '',
|
||||||
category_slug: item.category?.slug ?? '',
|
category_slug: item.category?.slug ?? '',
|
||||||
slug: item.slug ?? '',
|
slug: item.slug ?? '',
|
||||||
@@ -164,6 +166,36 @@ async function fetchRankApiArtworks(endpoint, rankType) {
|
|||||||
|
|
||||||
const SKELETON_COUNT = 10;
|
const SKELETON_COUNT = 10;
|
||||||
|
|
||||||
|
function getMasonryCardProps(art, idx) {
|
||||||
|
const title = (art.name || art.title || 'Untitled artwork').trim();
|
||||||
|
const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0;
|
||||||
|
const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null;
|
||||||
|
const categorySlug = (art.category_slug || '').toLowerCase();
|
||||||
|
const categoryName = (art.category_name || art.category || '').toLowerCase();
|
||||||
|
const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper'];
|
||||||
|
const wideCategoryNames = ['photography', 'wallpapers'];
|
||||||
|
const isWideEligible =
|
||||||
|
aspectRatio !== null &&
|
||||||
|
aspectRatio > 2.0 &&
|
||||||
|
(wideCategories.includes(categorySlug) || wideCategoryNames.includes(categoryName));
|
||||||
|
|
||||||
|
return {
|
||||||
|
articleClassName: `nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`,
|
||||||
|
articleStyle: isWideEligible ? { gridColumn: 'span 2' } : undefined,
|
||||||
|
frameClassName: 'rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 hover:ring-white/15 hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]',
|
||||||
|
mediaClassName: 'nova-card-media relative w-full overflow-hidden bg-neutral-900',
|
||||||
|
mediaStyle: hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : undefined,
|
||||||
|
imageSrcSet: art.thumb_srcset || undefined,
|
||||||
|
imageSizes: '(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw',
|
||||||
|
imageWidth: hasDimensions ? art.width : undefined,
|
||||||
|
imageHeight: hasDimensions ? art.height : undefined,
|
||||||
|
loading: idx < 8 ? 'eager' : 'lazy',
|
||||||
|
decoding: idx < 8 ? 'sync' : 'async',
|
||||||
|
fetchPriority: idx === 0 ? 'high' : undefined,
|
||||||
|
imageClassName: 'nova-card-main-image absolute inset-0 h-full w-full object-cover group-hover:scale-[1.03]',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* MasonryGallery
|
* MasonryGallery
|
||||||
@@ -309,7 +341,7 @@ function MasonryGallery({
|
|||||||
// ── Render ─────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="px-6 pb-10 pt-2 md:px-10 is-enhanced"
|
className="pb-10 pt-2 is-enhanced"
|
||||||
data-nova-gallery
|
data-nova-gallery
|
||||||
data-gallery-type={galleryType}
|
data-gallery-type={galleryType}
|
||||||
data-react-masonry-gallery
|
data-react-masonry-gallery
|
||||||
@@ -321,17 +353,14 @@ function MasonryGallery({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
className={gridClass}
|
|
||||||
data-gallery-grid
|
|
||||||
>
|
>
|
||||||
{artworks.map((art, idx) => (
|
<ArtworkGallery
|
||||||
<ArtworkCard
|
items={artworks}
|
||||||
key={`${art.id}-${idx}`}
|
layout="masonry"
|
||||||
art={art}
|
className={gridClass}
|
||||||
loading={idx < 8 ? 'eager' : 'lazy'}
|
containerProps={{ 'data-gallery-grid': true }}
|
||||||
fetchPriority={idx === 0 ? 'high' : undefined}
|
resolveCardProps={getMasonryCardProps}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Infinite scroll sentinel – placed after the grid */}
|
{/* Infinite scroll sentinel – placed after the grid */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import NovaConfirmDialog from '../ui/NovaConfirmDialog'
|
||||||
import ProfileCoverEditor from './ProfileCoverEditor'
|
import ProfileCoverEditor from './ProfileCoverEditor'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
|||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
|
||||||
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [pendingFollowState, setPendingFollowState] = useState(null)
|
||||||
|
|
||||||
const uname = user.username || user.name || 'Unknown'
|
const uname = user.username || user.name || 'Unknown'
|
||||||
const displayName = user.name || uname
|
const displayName = user.name || uname
|
||||||
@@ -23,7 +26,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
|||||||
|
|
||||||
const bio = profile?.bio || profile?.about || ''
|
const bio = profile?.bio || profile?.about || ''
|
||||||
|
|
||||||
const toggleFollow = async () => {
|
const persistFollowState = async (nextState) => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +46,29 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
const nextState = !following
|
||||||
|
if (!nextState) {
|
||||||
|
setPendingFollowState(nextState)
|
||||||
|
setConfirmOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistFollowState(nextState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onConfirmUnfollow = async () => {
|
||||||
|
if (pendingFollowState === null) return
|
||||||
|
setConfirmOpen(false)
|
||||||
|
await persistFollowState(pendingFollowState)
|
||||||
|
setPendingFollowState(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCloseConfirm = () => {
|
||||||
|
setConfirmOpen(false)
|
||||||
|
setPendingFollowState(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-6xl mx-auto px-4 pt-4">
|
<div className="max-w-6xl mx-auto px-4 pt-4">
|
||||||
@@ -228,6 +254,18 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
|
|||||||
setCoverPosition(50)
|
setCoverPosition(50)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NovaConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
title="Unfollow creator?"
|
||||||
|
message={`You will stop seeing updates from @${uname} in your following feed.`}
|
||||||
|
confirmLabel="Unfollow"
|
||||||
|
cancelLabel="Keep following"
|
||||||
|
confirmTone="danger"
|
||||||
|
onConfirm={onConfirmUnfollow}
|
||||||
|
onClose={onCloseConfirm}
|
||||||
|
busy={loading}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import ArtworkCard from '../../gallery/ArtworkCard'
|
import ArtworkGallery from '../../artwork/ArtworkGallery'
|
||||||
|
|
||||||
function FavSkeleton() {
|
function FavSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -57,16 +57,16 @@ export default function TabFavourites({ favourites, isOwner, username }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
<ArtworkGallery
|
||||||
{items.map((art, i) => (
|
items={items}
|
||||||
<ArtworkCard
|
layout="grid"
|
||||||
key={art.id ?? i}
|
className="grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
|
||||||
art={art}
|
resolveCardProps={(_, index) => ({
|
||||||
loading={i < 8 ? 'eager' : 'lazy'}
|
loading: index < 8 ? 'eager' : 'lazy',
|
||||||
/>
|
})}
|
||||||
))}
|
>
|
||||||
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
|
{loadingMore && Array.from({ length: 4 }).map((_, i) => <FavSkeleton key={`sk-${i}`} />)}
|
||||||
</div>
|
</ArtworkGallery>
|
||||||
|
|
||||||
{nextCursor && (
|
{nextCursor && (
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
|
|||||||
94
resources/js/components/ui/NovaConfirmDialog.jsx
Normal file
94
resources/js/components/ui/NovaConfirmDialog.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export default function NovaConfirmDialog({
|
||||||
|
open,
|
||||||
|
title = 'Please confirm',
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
confirmTone = 'danger',
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
busy = false,
|
||||||
|
}) {
|
||||||
|
const backdropRef = useRef(null)
|
||||||
|
const cancelButtonRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined
|
||||||
|
const timeoutId = window.setTimeout(() => cancelButtonRef.current?.focus(), 60)
|
||||||
|
return () => window.clearTimeout(timeoutId)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape' && !busy) {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [busy, onClose, open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const confirmClassName = confirmTone === 'danger'
|
||||||
|
? 'border border-rose-400/25 bg-rose-500/12 text-rose-100 hover:bg-rose-500/18 focus-visible:ring-rose-300/50'
|
||||||
|
: 'border border-accent/25 bg-accent/90 text-deep hover:brightness-110 focus-visible:ring-accent/50'
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === backdropRef.current && !busy) {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="nova-confirm-title"
|
||||||
|
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]"
|
||||||
|
>
|
||||||
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Skinbase Nova</p>
|
||||||
|
<h3 id="nova-confirm-title" className="mt-2 text-lg font-semibold text-white">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<p className="text-sm leading-6 text-white/70">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||||
|
<button
|
||||||
|
ref={cancelButtonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClose?.()}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onConfirm?.()}
|
||||||
|
disabled={busy}
|
||||||
|
className={`inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60 ${confirmClassName}`}
|
||||||
|
>
|
||||||
|
{busy ? 'Working…' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<ul class="submenu">
|
<ul class="submenu">
|
||||||
<li><a href="/forum"><i class="fa fa-comments fa-fw"></i> Forum</a></li>
|
<li><a href="/forum"><i class="fa fa-comments fa-fw"></i> Forum</a></li>
|
||||||
<li><a href="/chat"><i class="fa fa-comments fa-fw"></i> Chat</a></li>
|
<li><a href="{{ route('community.chat') }}"><i class="fa fa-comments fa-fw"></i> Chat</a></li>
|
||||||
|
<li><a href="/community/activity"><i class="fa fa-wave-square fa-fw"></i> Activity Feed</a></li>
|
||||||
<li><a href="/browse-categories"><i class="fa fa-cloud fa-fw"></i> Categories</a></li>
|
<li><a href="/browse-categories"><i class="fa fa-cloud fa-fw"></i> Categories</a></li>
|
||||||
<li><a href="/latest-artworks"><i class="fa fa-trophy fa-fw"></i> Latest Uploads</a></li>
|
<li><a href="/latest-artworks"><i class="fa fa-trophy fa-fw"></i> Latest Uploads</a></li>
|
||||||
<li><a href="/daily-uploads"><i class="fa fa-trophy fa-fw"></i> Recent Uploads</a></li>
|
<li><a href="/daily-uploads"><i class="fa fa-trophy fa-fw"></i> Recent Uploads</a></li>
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
<li><a href="/interviews"><i class="fa fa-users fa-fw"></i> Interviews</a></li>
|
<li><a href="/interviews"><i class="fa fa-users fa-fw"></i> Interviews</a></li>
|
||||||
<li><a href="/Members/MembersPhotos/545"><i class="fa fa-users fa-fw"></i> Members Photos</a></li>
|
<li><a href="/Members/MembersPhotos/545"><i class="fa fa-users fa-fw"></i> Members Photos</a></li>
|
||||||
<li><a href="/top-authors"><i class="fa fa-users fa-fw"></i> Top Authors</a></li>
|
<li><a href="/top-authors"><i class="fa fa-users fa-fw"></i> Top Authors</a></li>
|
||||||
<li><a href="/latest-comments"><i class="fa fa-bar-chart fa-fw"></i> Latest Comments</a></li>
|
<li><a href="/community/activity"><i class="fa fa-wave-square fa-fw"></i> Activity Feed</a></li>
|
||||||
<li><a href="/monthly-commentators"><i class="fa fa-bar-chart fa-fw"></i> Monthly Top Comments</a></li>
|
<li><a href="/monthly-commentators"><i class="fa fa-bar-chart fa-fw"></i> Monthly Top Comments</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title, 'url' => route('legacy.latest_comments')],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-fluid legacy-page">
|
<x-nova-page-header
|
||||||
<div class="effect2 page-header-wrap">
|
section="Comments"
|
||||||
<header class="page-heading">
|
:title="$page_title"
|
||||||
<h1 class="page-header">{{ $page_title }}</h1>
|
icon="fa-comments"
|
||||||
<p>List of artwork with latest comments received.</p>
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
</header>
|
description="List of artwork with latest comments received."
|
||||||
</div>
|
headerClass="pb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="container-fluid legacy-page pt-8">
|
||||||
|
|
||||||
<div class="masonry">
|
<div class="masonry">
|
||||||
@foreach ($comments as $comment)
|
@foreach ($comments as $comment)
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title, 'url' => route('legacy.monthly_commentators')],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-fluid legacy-page">
|
<x-nova-page-header
|
||||||
<div class="effect2 page-header-wrap">
|
section="Comments"
|
||||||
<header class="page-heading">
|
:title="$page_title"
|
||||||
<h1 class="page-header">{{ $page_title }}</h1>
|
icon="fa-ranking-star"
|
||||||
<p>List of users who post the most comments in the current month.</p>
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
</header>
|
description="List of users who post the most comments in the current month."
|
||||||
</div>
|
headerClass="pb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="container-fluid legacy-page pt-8">
|
||||||
|
|
||||||
<div class="container-main">
|
<div class="container-main">
|
||||||
<div class="panel panel-default effect2">
|
<div class="panel panel-default effect2">
|
||||||
|
|||||||
@@ -690,5 +690,4 @@
|
|||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container mx-auto py-8">
|
<x-nova-page-header
|
||||||
<h1 class="text-2xl font-semibold mb-4">Comments</h1>
|
section="Dashboard"
|
||||||
|
title="Comments"
|
||||||
|
icon="fa-comments"
|
||||||
|
:breadcrumbs="collect([
|
||||||
|
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||||
|
(object) ['name' => 'Comments', 'url' => route('dashboard.comments')],
|
||||||
|
])"
|
||||||
|
description="Comments across your dashboard activity and conversations."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="px-6 pb-16 pt-8 md:px-10">
|
||||||
@if(empty($comments))
|
@if(empty($comments))
|
||||||
<p class="text-sm text-gray-500">No comments to show.</p>
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||||
|
<p class="text-sm text-white/40">No comments to show.</p>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<ul class="space-y-2">
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-6">
|
||||||
@foreach($comments as $c)
|
<ul class="space-y-2">
|
||||||
<li>{{ $c }}</li>
|
@foreach($comments as $c)
|
||||||
@endforeach
|
<li class="text-sm text-white/75">{{ $c }}</li>
|
||||||
</ul>
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -25,59 +25,32 @@
|
|||||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
@push('head')
|
|
||||||
<style>
|
|
||||||
.nb-hero-fade {
|
|
||||||
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
|
|
||||||
}
|
|
||||||
.nb-hero-gradient {
|
|
||||||
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
|
|
||||||
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
@keyframes nb-hero-shimmer {
|
|
||||||
0% { opacity: 0.6; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@endpush
|
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-fluid legacy-page">
|
<div class="container-fluid legacy-page">
|
||||||
<div class="pt-0">
|
<div class="pt-0">
|
||||||
<div class="mx-auto w-full">
|
<div class="mx-auto w-full">
|
||||||
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
||||||
<main class="w-full">
|
<main class="w-full">
|
||||||
<div class="relative overflow-hidden nb-hero-radial">
|
<x-nova-page-header
|
||||||
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
|
section="Dashboard"
|
||||||
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
|
title="My Favourites"
|
||||||
|
icon="fa-heart"
|
||||||
<div class="relative px-6 py-10 md:px-10 md:py-14">
|
:breadcrumbs="collect([
|
||||||
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
|
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||||
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
|
(object) ['name' => 'Favourites', 'url' => route('dashboard.favorites')],
|
||||||
<span class="opacity-40" aria-hidden="true">›</span>
|
])"
|
||||||
<span class="text-white/85">Favourites</span>
|
description="Artworks you saved, displayed in the same gallery layout as Browse."
|
||||||
</nav>
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
<div class="mt-4 py-5">
|
<x-slot name="actions">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
|
@if($artworks->total() > 0)
|
||||||
My Favourites
|
<div class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-1.5 text-sm text-white/60">
|
||||||
</h1>
|
<i class="fa-solid fa-images text-xs text-sky-300"></i>
|
||||||
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">
|
<span>{{ number_format($artworks->total()) }} artworks</span>
|
||||||
Artworks you saved, displayed in the same gallery layout as Browse.
|
</div>
|
||||||
</p>
|
@endif
|
||||||
@if($artworks->total() > 0)
|
</x-slot>
|
||||||
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
|
</x-nova-page-header>
|
||||||
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>{{ number_format($artworks->total()) }} artworks</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="px-6 pb-10 pt-8 md:px-10">
|
<section class="px-6 pb-10 pt-8 md:px-10">
|
||||||
@if($artworks->isEmpty())
|
@if($artworks->isEmpty())
|
||||||
|
|||||||
@@ -1,94 +1,111 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="px-6 pt-10 pb-16 md:px-10">
|
<div class="container-fluid legacy-page">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div class="pt-0">
|
||||||
<div>
|
<div class="mx-auto w-full">
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Dashboard</p>
|
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">People I Follow</h1>
|
<main class="w-full">
|
||||||
<p class="mt-1 text-sm text-white/50">Creators and members you follow, with quick stats and recent follow time.</p>
|
<x-nova-page-header
|
||||||
</div>
|
section="Dashboard"
|
||||||
|
title="People I Follow"
|
||||||
|
icon="fa-user-group"
|
||||||
|
:breadcrumbs="collect([
|
||||||
|
(object) ['name' => 'Dashboard', 'url' => '/dashboard'],
|
||||||
|
(object) ['name' => 'Following', 'url' => route('dashboard.following')],
|
||||||
|
])"
|
||||||
|
description="Creators and members you follow, with quick stats and recent follow time."
|
||||||
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<a href="{{ route('discover.trending') }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||||
|
<i class="fa-solid fa-compass text-xs"></i>
|
||||||
|
Discover creators
|
||||||
|
</a>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
<a href="{{ route('discover.trending') }}"
|
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
|
||||||
<i class="fa-solid fa-compass text-xs"></i>
|
|
||||||
Discover creators
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($following->isEmpty())
|
@if($following->isEmpty())
|
||||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||||
<p class="text-white/40 text-sm">You are not following anyone yet.</p>
|
<p class="text-white/40 text-sm">You are not following anyone yet.</p>
|
||||||
<a href="{{ route('discover.trending') }}" class="mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
<a href="{{ route('discover.trending') }}" class="mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||||
<i class="fa-solid fa-compass text-xs"></i>
|
<i class="fa-solid fa-compass text-xs"></i>
|
||||||
Start following creators
|
Start following creators
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
@php
|
|
||||||
$firstFollow = $following->getCollection()->first();
|
|
||||||
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
|
||||||
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
|
||||||
: null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
|
||||||
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
|
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->total()) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
|
||||||
<p class="text-xs uppercase tracking-widest text-white/35">On this page</p>
|
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->count()) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4 sm:col-span-2 xl:col-span-1">
|
|
||||||
<p class="text-xs uppercase tracking-widest text-white/35">Last followed</p>
|
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">{{ $latestFollowedAt ?? '—' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
|
||||||
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Creator</span>
|
|
||||||
<span class="hidden sm:block text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Stats</span>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Followed</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divide-y divide-white/[0.04]">
|
|
||||||
@foreach($following as $f)
|
|
||||||
@php
|
|
||||||
$displayName = $f->name ?: $f->uname;
|
|
||||||
@endphp
|
|
||||||
<a href="{{ $f->profile_url }}"
|
|
||||||
class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-4 hover:bg-white/[0.03] transition-colors">
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
<img src="{{ $f->avatar_url }}"
|
|
||||||
alt="{{ $displayName }}"
|
|
||||||
class="w-11 h-11 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.10]"
|
|
||||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="truncate text-sm font-semibold text-white/90">{{ $displayName }}</div>
|
|
||||||
@if(!empty($f->username))
|
|
||||||
<div class="truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@else
|
||||||
|
@php
|
||||||
|
$firstFollow = $following->getCollection()->first();
|
||||||
|
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
|
||||||
|
? \Carbon\Carbon::parse($firstFollow->followed_at)->diffForHumans()
|
||||||
|
: null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div class="hidden sm:block text-right text-xs text-white/55">
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{{ number_format((int) $f->uploads) }} uploads · {{ number_format((int) $f->followers_count) }} followers
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||||
</div>
|
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->total()) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/35">On this page</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($following->count()) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4 sm:col-span-2 xl:col-span-1">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/35">Last followed</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ $latestFollowedAt ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-right text-xs text-white/45 whitespace-nowrap">
|
<div class="rounded-xl border border-white/[0.06] overflow-hidden">
|
||||||
{{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : '—' }}
|
<div class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-3 bg-white/[0.03] border-b border-white/[0.06]">
|
||||||
</div>
|
<span class="text-xs font-semibold uppercase tracking-widest text-white/30">Creator</span>
|
||||||
</a>
|
<span class="hidden sm:block text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Stats</span>
|
||||||
@endforeach
|
<span class="text-xs font-semibold uppercase tracking-widest text-white/30 text-right">Followed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divide-y divide-white/[0.04]">
|
||||||
|
@foreach($following as $f)
|
||||||
|
@php
|
||||||
|
$displayName = $f->name ?: $f->uname;
|
||||||
|
@endphp
|
||||||
|
<a href="{{ $f->profile_url }}"
|
||||||
|
class="grid grid-cols-[1fr_auto_auto] items-center gap-4 px-5 py-4 hover:bg-white/[0.03] transition-colors">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<img src="{{ $f->avatar_url }}"
|
||||||
|
alt="{{ $displayName }}"
|
||||||
|
class="w-11 h-11 rounded-full object-cover flex-shrink-0 ring-1 ring-white/[0.10]"
|
||||||
|
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-semibold text-white/90">{{ $displayName }}</div>
|
||||||
|
@if(!empty($f->username))
|
||||||
|
<div class="truncate text-xs text-white/35">{{ '@' . $f->username }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden sm:block text-right text-xs text-white/55">
|
||||||
|
{{ number_format((int) $f->uploads) }} uploads · {{ number_format((int) $f->followers_count) }} followers
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right text-xs text-white/45 whitespace-nowrap">
|
||||||
|
{{ !empty($f->followed_at) ? \Carbon\Carbon::parse($f->followed_at)->diffForHumans() : '—' }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
{{ $following->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
{{ $following->links() }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -42,17 +42,21 @@
|
|||||||
{{-- Minimal hero --}}
|
{{-- Minimal hero --}}
|
||||||
@if(!empty($center_content))
|
@if(!empty($center_content))
|
||||||
<x-centered-content :max="$center_max ?? '3xl'" class="pt-10 pb-6" style="padding-top:2.5rem;padding-bottom:1.5rem;">
|
<x-centered-content :max="$center_max ?? '3xl'" class="pt-10 pb-6" style="padding-top:2.5rem;padding-bottom:1.5rem;">
|
||||||
{{-- Breadcrumbs --}}
|
@hasSection('page-hero')
|
||||||
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
@yield('page-hero')
|
||||||
|
@else
|
||||||
|
{{-- Breadcrumbs --}}
|
||||||
|
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">
|
<h1 class="text-3xl font-bold text-white leading-tight">
|
||||||
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
||||||
</h1>
|
</h1>
|
||||||
@isset($hero_description)
|
@isset($hero_description)
|
||||||
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
||||||
@endisset
|
@endisset
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</x-centered-content>
|
</x-centered-content>
|
||||||
|
|
||||||
{{-- Page body (centered) --}}
|
{{-- Page body (centered) --}}
|
||||||
@@ -61,17 +65,21 @@
|
|||||||
</x-centered-content>
|
</x-centered-content>
|
||||||
@else
|
@else
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||||
{{-- Breadcrumbs --}}
|
@hasSection('page-hero')
|
||||||
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
@yield('page-hero')
|
||||||
|
@else
|
||||||
|
{{-- Breadcrumbs --}}
|
||||||
|
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">
|
<h1 class="text-3xl font-bold text-white leading-tight">
|
||||||
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
||||||
</h1>
|
</h1>
|
||||||
@isset($hero_description)
|
@isset($hero_description)
|
||||||
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
||||||
@endisset
|
@endisset
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Page body --}}
|
{{-- Page body --}}
|
||||||
|
|||||||
@@ -75,11 +75,11 @@
|
|||||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'browse' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'browse' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||||
data-dd="browse"
|
data-dd="browse"
|
||||||
{{ $navSection === 'browse' ? 'aria-current=page' : '' }}>
|
{{ $navSection === 'browse' ? 'aria-current=page' : '' }}>
|
||||||
Browse
|
Explore
|
||||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="dd-browse" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
<div id="dd-browse" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/browse">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/explore">
|
||||||
<i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks
|
<i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks
|
||||||
</a>
|
</a>
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/photography">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/photography">
|
||||||
@@ -135,11 +135,14 @@
|
|||||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div id="dd-community" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
<div id="dd-community" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||||
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('community.activity') }}">
|
||||||
|
<i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed
|
||||||
|
</a>
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/forum">
|
||||||
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
|
<i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum
|
||||||
</a>
|
</a>
|
||||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/news">
|
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/news">
|
||||||
<i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements
|
<i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,8 +397,9 @@
|
|||||||
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
|
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
|
||||||
</button>
|
</button>
|
||||||
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
|
||||||
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('community.activity') }}"><i class="fa-solid fa-wave-square w-4 text-center text-sb-muted"></i>Activity Feed</a>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
|
||||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements</a>
|
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>News</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
{{-- Reusable article card partial --}}
|
<article class="group overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
|
||||||
<div class="card h-100 border-0 shadow-sm news-card">
|
<a href="{{ route('news.show', $article->slug) }}" class="block">
|
||||||
@if($article->cover_url)
|
<div class="relative aspect-[16/9] overflow-hidden bg-black/20">
|
||||||
<a href="{{ route('news.show', $article->slug) }}">
|
@if($article->cover_url)
|
||||||
<img src="{{ $article->cover_url }}" class="card-img-top"
|
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]">
|
||||||
alt="{{ $article->title }}"
|
@else
|
||||||
style="height:180px;object-fit:cover;">
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.98))]"></div>
|
||||||
</a>
|
@endif
|
||||||
@endif
|
<div class="absolute inset-0 bg-gradient-to-t from-[#020611cc] via-transparent to-transparent"></div>
|
||||||
<div class="card-body d-flex flex-column">
|
</div>
|
||||||
@if($article->category)
|
</a>
|
||||||
<a href="{{ route('news.category', $article->category->slug) }}"
|
|
||||||
class="badge badge-primary mb-2 align-self-start">{{ $article->category->name }}</a>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<h6 class="card-title mb-1">
|
<div class="flex h-full flex-col p-5">
|
||||||
<a href="{{ route('news.show', $article->slug) }}" class="text-dark text-decoration-none">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{{ $article->title }}
|
@if($article->category)
|
||||||
</a>
|
<a href="{{ route('news.category', $article->category->slug) }}" class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-500/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-200">{{ $article->category->name }}</a>
|
||||||
</h6>
|
@endif
|
||||||
|
<span class="text-[11px] uppercase tracking-[0.16em] text-white/30">{{ $article->published_at?->format('d M Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mt-3 text-xl font-semibold leading-tight text-white/95">
|
||||||
|
<a href="{{ route('news.show', $article->slug) }}" class="transition hover:text-sky-200">{{ $article->title }}</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
@if($article->excerpt)
|
@if($article->excerpt)
|
||||||
<p class="card-text text-muted small flex-grow-1">{{ Str::limit($article->excerpt, 100) }}</p>
|
<p class="mt-3 flex-1 text-sm leading-7 text-white/55">{{ Str::limit(strip_tags((string) $article->excerpt), 135) }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="text-muted small mt-2 d-flex justify-content-between">
|
<div class="mt-4 flex items-center justify-between gap-3 text-sm text-white/40">
|
||||||
<span>{{ $article->published_at?->format('d M Y') }}</span>
|
<span class="truncate">{{ $article->author?->name ?? 'Skinbase' }}</span>
|
||||||
<span><i class="fas fa-eye mr-1"></i>{{ number_format($article->views) }}</span>
|
<span class="shrink-0 inline-flex items-center gap-1.5">
|
||||||
|
<i class="fa-regular fa-eye text-[11px]"></i>
|
||||||
|
{{ number_format((int) $article->views) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
|||||||
@@ -1,59 +1,58 @@
|
|||||||
{{-- Sidebar partial for news frontend --}}
|
|
||||||
|
|
||||||
{{-- Categories widget --}}
|
|
||||||
@if(!empty($categories) && $categories->isNotEmpty())
|
@if(!empty($categories) && $categories->isNotEmpty())
|
||||||
<div class="card mb-4">
|
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||||
<div class="card-header"><strong>Categories</strong></div>
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<div class="list-group list-group-flush">
|
<h2 class="text-sm font-semibold uppercase tracking-[0.18em] text-white/45">Categories</h2>
|
||||||
|
<span class="text-xs text-white/30">{{ $categories->count() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
@foreach($categories as $cat)
|
@foreach($categories as $cat)
|
||||||
<a href="{{ route('news.category', $cat->slug) }}"
|
<a href="{{ route('news.category', $cat->slug) }}" class="flex items-center justify-between rounded-2xl px-3 py-2.5 text-sm text-white/65 transition hover:bg-white/[0.04] hover:text-white">
|
||||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
<span>{{ $cat->name }}</span>
|
||||||
{{ $cat->name }}
|
<span class="rounded-full border border-white/[0.06] bg-white/[0.04] px-2 py-0.5 text-[11px] text-white/45">{{ number_format((int) ($cat->published_articles_count ?? 0)) }}</span>
|
||||||
<span class="badge badge-secondary badge-pill">{{ $cat->published_articles_count ?? 0 }}</span>
|
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Trending articles --}}
|
|
||||||
@if(!empty($trending) && $trending->isNotEmpty())
|
@if(!empty($trending) && $trending->isNotEmpty())
|
||||||
<div class="card mb-4">
|
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||||
<div class="card-header"><strong><i class="fas fa-fire mr-1 text-danger"></i> Trending</strong></div>
|
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
|
||||||
<div class="list-group list-group-flush">
|
<i class="fa-solid fa-fire text-[11px] text-rose-300"></i>
|
||||||
|
Trending
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
@foreach($trending as $item)
|
@foreach($trending as $item)
|
||||||
<a href="{{ route('news.show', $item->slug) }}"
|
<a href="{{ route('news.show', $item->slug) }}" class="block rounded-2xl border border-white/[0.04] bg-black/10 px-4 py-3 transition hover:border-white/[0.08] hover:bg-white/[0.03]">
|
||||||
class="list-group-item list-group-item-action py-2">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<span class="text-sm font-medium leading-6 text-white/80">{{ Str::limit($item->title, 70) }}</span>
|
||||||
<span class="font-weight-bold small">{{ Str::limit($item->title, 55) }}</span>
|
<span class="shrink-0 rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-0.5 text-[11px] text-sky-200">{{ number_format((int) $item->views) }}</span>
|
||||||
<span class="badge badge-info badge-pill ml-2">{{ number_format($item->views) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ $item->published_at?->diffForHumans() }}</small>
|
<p class="mt-1 text-xs text-white/35">{{ $item->published_at?->diffForHumans() }}</p>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Tags cloud --}}
|
|
||||||
@if(!empty($tags) && $tags->isNotEmpty())
|
@if(!empty($tags) && $tags->isNotEmpty())
|
||||||
<div class="card mb-4">
|
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||||
<div class="card-header"><strong><i class="fas fa-tags mr-1"></i> Tags</strong></div>
|
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
|
||||||
<div class="card-body">
|
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
|
||||||
|
Topics
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
@foreach($tags as $tag)
|
@foreach($tags as $tag)
|
||||||
<a href="{{ route('news.tag', $tag->slug) }}" class="badge badge-secondary mr-1 mb-1">
|
<a href="{{ route('news.tag', $tag->slug) }}" class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/[0.14] hover:bg-white/[0.06] hover:text-white">#{{ $tag->name }}</a>
|
||||||
{{ $tag->name }}
|
|
||||||
</a>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- RSS link --}}
|
<section class="rounded-[24px] border border-amber-400/20 bg-amber-500/10 p-5 text-center">
|
||||||
<div class="card mb-4">
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-100/70">Stay updated</p>
|
||||||
<div class="card-body text-center">
|
<a href="{{ route('news.rss') }}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-100 transition hover:bg-amber-500/20" target="_blank" rel="noopener noreferrer">
|
||||||
<a href="{{ route('news.rss') }}" class="btn btn-outline-warning btn-sm" target="_blank">
|
<i class="fa-solid fa-rss text-xs"></i>
|
||||||
<i class="fas fa-rss mr-1"></i> RSS Feed
|
RSS Feed
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
@extends('news.layout', [
|
@extends('news.layout', [
|
||||||
'metaTitle' => $category->name . ' — News',
|
'metaTitle' => $category->name . ' — News',
|
||||||
|
'metaDescription' => $category->description ?: ('Announcements in the ' . $category->name . ' category.'),
|
||||||
|
'metaCanonical' => route('news.category', $category->slug),
|
||||||
])
|
])
|
||||||
|
|
||||||
@section('news_content')
|
@section('news_content')
|
||||||
<div class="container py-5">
|
@php
|
||||||
<h1 class="mb-1">{{ $category->name }}</h1>
|
$headerBreadcrumbs = collect([
|
||||||
@if($category->description)
|
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||||
<p class="text-muted mb-4">{{ $category->description }}</p>
|
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||||
@endif
|
(object) ['name' => $category->name, 'url' => route('news.category', $category->slug)],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div class="row">
|
<x-nova-page-header
|
||||||
<div class="col-lg-8">
|
section="Community"
|
||||||
<div class="row">
|
:title="$category->name"
|
||||||
@forelse($articles as $article)
|
icon="fa-folder-open"
|
||||||
<div class="col-sm-6 mb-4">
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
@include('news._article_card', ['article' => $article])
|
:description="$category->description ?: ('Announcements filed under ' . $category->name . '.')"
|
||||||
|
headerClass="pb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 pt-8 pb-16 md:px-10">
|
||||||
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<section>
|
||||||
|
@if($articles->isEmpty())
|
||||||
|
<div class="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-8 py-14 text-center text-white/45">No articles in this category yet.</div>
|
||||||
|
@else
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
@foreach($articles as $article)
|
||||||
|
@include('news._article_card', ['article' => $article])
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@empty
|
<div class="mt-8 flex justify-center">{{ $articles->links() }}</div>
|
||||||
<div class="col-12 text-center text-muted py-5">No articles in this category.</div>
|
@endif
|
||||||
@endforelse
|
</section>
|
||||||
</div>
|
<aside class="space-y-4">
|
||||||
<div class="mt-3">{{ $articles->links() }}</div>
|
@include('news._sidebar', ['categories' => $categories, 'trending' => $trending, 'tags' => $tags])
|
||||||
</div>
|
</aside>
|
||||||
<div class="col-lg-4">
|
|
||||||
@include('news._sidebar', ['categories' => $categories])
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,69 +1,105 @@
|
|||||||
@extends('news.layout', [
|
@extends('news.layout', [
|
||||||
'metaTitle' => config('news.rss_title', 'News'),
|
'metaTitle' => config('news.rss_title', 'News'),
|
||||||
'metaDescription' => config('news.rss_description', ''),
|
'metaDescription' => config('news.rss_description', ''),
|
||||||
|
'metaCanonical' => route('news.index'),
|
||||||
])
|
])
|
||||||
|
|
||||||
@section('news_content')
|
@section('news_content')
|
||||||
<div class="news-index">
|
@php
|
||||||
<div class="container py-5">
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||||
|
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
{{-- Featured article --}}
|
<x-nova-page-header
|
||||||
@if($featured)
|
section="Community"
|
||||||
<section class="mb-5">
|
title="News"
|
||||||
<a href="{{ route('news.show', $featured->slug) }}" class="text-decoration-none">
|
icon="fa-newspaper"
|
||||||
<div class="card border-0 shadow-sm overflow-hidden news-featured">
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
@if($featured->cover_url)
|
:description="config('news.rss_description', 'Latest news, feature rollouts, and team updates from Skinbase.')"
|
||||||
<img src="{{ $featured->cover_url }}" class="card-img" alt="{{ $featured->title }}"
|
headerClass="pb-6"
|
||||||
style="height:400px;object-fit:cover;">
|
>
|
||||||
@endif
|
<x-slot name="actions">
|
||||||
<div class="card-img-overlay d-flex align-items-end p-4"
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
style="background:linear-gradient(transparent,rgba(0,0,0,0.75))">
|
@if(($articles->total() ?? 0) > 0)
|
||||||
<div class="text-white">
|
<span class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1.5 text-white/65">
|
||||||
@if($featured->category)
|
<i class="fa-solid fa-file-lines text-xs text-sky-300"></i>
|
||||||
<span class="badge badge-primary mb-2">{{ $featured->category->name }}</span>
|
{{ number_format($articles->total()) }} articles
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('news.rss') }}" class="inline-flex items-center gap-2 rounded-lg border border-amber-400/20 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-200 transition hover:bg-amber-500/15">
|
||||||
|
<i class="fa-solid fa-rss text-xs"></i>
|
||||||
|
RSS feed
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 pt-8 pb-16 md:px-10">
|
||||||
|
@if($featured)
|
||||||
|
<section class="mb-8">
|
||||||
|
<a href="{{ route('news.show', $featured->slug) }}" class="group block overflow-hidden rounded-[32px] border border-white/[0.08] bg-white/[0.03] shadow-[0_24px_60px_rgba(0,0,0,0.24)] transition hover:border-white/[0.12]">
|
||||||
|
<div class="grid lg:grid-cols-[1.25fr_0.95fr]">
|
||||||
|
<div class="relative min-h-[280px] overflow-hidden bg-black/20">
|
||||||
|
@if($featured->cover_url)
|
||||||
|
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]">
|
||||||
|
@else
|
||||||
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_45%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.98))]"></div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-[#020611] via-[#02061166] to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-between gap-5 p-6 lg:p-8">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/45">
|
||||||
|
<span class="text-sky-300">Featured story</span>
|
||||||
|
@if($featured->category)
|
||||||
|
<span class="rounded-full border border-sky-400/20 bg-sky-500/10 px-2.5 py-1 text-[11px] tracking-[0.12em] text-sky-200">{{ $featured->category->name }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-4 text-3xl font-bold leading-tight text-white/95">{{ $featured->title }}</h2>
|
||||||
|
@if($featured->excerpt)
|
||||||
|
<p class="mt-4 text-sm leading-7 text-white/60">{{ Str::limit(strip_tags((string) $featured->excerpt), 220) }}</p>
|
||||||
@endif
|
@endif
|
||||||
<h2 class="font-weight-bold">{{ $featured->title }}</h2>
|
</div>
|
||||||
<p class="mb-1">{{ Str::limit(strip_tags((string)$featured->excerpt), 180) }}</p>
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/45">
|
||||||
<small>
|
<span>{{ $featured->author?->name ?? 'Skinbase' }}</span>
|
||||||
{{ $featured->author?->name }} ·
|
<span>{{ $featured->published_at?->format('d M Y') }}</span>
|
||||||
{{ $featured->published_at?->format('d M Y') }} ·
|
<span>{{ $featured->reading_time }} min read</span>
|
||||||
{{ $featured->reading_time }} min read
|
<span>{{ number_format((int) $featured->views) }} views</span>
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="row">
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
{{-- Articles grid --}}
|
<section>
|
||||||
<div class="col-lg-8">
|
@if($articles->isEmpty())
|
||||||
<div class="row">
|
<div class="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-8 py-14 text-center text-white/45">
|
||||||
@forelse($articles as $article)
|
No announcements have been published yet.
|
||||||
<div class="col-sm-6 mb-4">
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
@foreach($articles as $article)
|
||||||
@include('news._article_card', ['article' => $article])
|
@include('news._article_card', ['article' => $article])
|
||||||
</div>
|
@endforeach
|
||||||
@empty
|
|
||||||
<div class="col-12 text-center text-muted py-5">No news articles published yet.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-8 flex justify-center">
|
||||||
{{ $articles->links() }}
|
{{ $articles->links() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
@endif
|
||||||
|
</section>
|
||||||
{{-- Sidebar --}}
|
|
||||||
<div class="col-lg-4">
|
|
||||||
@include('news._sidebar', [
|
|
||||||
'categories' => $categories,
|
|
||||||
'trending' => $trending,
|
|
||||||
'tags' => $tags,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<aside class="space-y-4">
|
||||||
|
@include('news._sidebar', [
|
||||||
|
'categories' => $categories,
|
||||||
|
'trending' => $trending,
|
||||||
|
'tags' => $tags,
|
||||||
|
])
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
{{--
|
@extends('layouts.nova')
|
||||||
Frontend layout wrapper for the News section.
|
|
||||||
Extends the main app layout.
|
|
||||||
--}}
|
|
||||||
@extends('layouts.app')
|
|
||||||
|
|
||||||
@section('title', $metaTitle ?? config('news.rss_title', 'News'))
|
@php
|
||||||
|
$page_title = $metaTitle ?? config('news.rss_title', 'News');
|
||||||
@if(isset($metaDescription))
|
$page_meta_description = $metaDescription ?? config('news.rss_description', 'Latest announcements and community updates from Skinbase.');
|
||||||
@section('meta_description', $metaDescription)
|
$page_canonical = $metaCanonical ?? url()->current();
|
||||||
@endif
|
$page_robots = $metaRobots ?? 'index,follow';
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@yield('news_content')
|
@yield('news_content')
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
@extends('news.layout', [
|
@extends('news.layout', [
|
||||||
'metaTitle' => $article->meta_title ?: $article->title,
|
'metaTitle' => $article->meta_title ?: $article->title,
|
||||||
'metaDescription' => $article->meta_description ?: Str::limit(strip_tags((string)$article->excerpt), 160),
|
'metaDescription' => $article->meta_description ?: Str::limit(strip_tags((string)$article->excerpt), 160),
|
||||||
|
'metaCanonical' => route('news.show', $article->slug),
|
||||||
])
|
])
|
||||||
|
|
||||||
@section('news_content')
|
@section('news_content')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||||
|
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||||
|
$article->category
|
||||||
|
? (object) ['name' => $article->category->name, 'url' => route('news.category', $article->category->slug)]
|
||||||
|
: null,
|
||||||
|
(object) ['name' => $article->title, 'url' => route('news.show', $article->slug)],
|
||||||
|
])->filter()->values();
|
||||||
|
@endphp
|
||||||
|
|
||||||
{{-- OpenGraph meta --}}
|
{{-- OpenGraph meta --}}
|
||||||
@push('head')
|
@push('head')
|
||||||
<meta property="og:type" content="article">
|
<meta property="og:type" content="article">
|
||||||
@@ -20,94 +32,102 @@
|
|||||||
@endif
|
@endif
|
||||||
@endpush
|
@endpush
|
||||||
|
|
||||||
<div class="news-article">
|
<x-nova-page-header
|
||||||
<div class="container py-5">
|
section="Community"
|
||||||
<div class="row">
|
:title="$article->title"
|
||||||
<div class="col-lg-8">
|
icon="fa-newspaper"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
:description="$article->excerpt ? Str::limit(strip_tags((string) $article->excerpt), 180) : 'Latest Skinbase announcement and community update.'"
|
||||||
|
headerClass="pb-6"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm text-white/60">
|
||||||
|
@if($article->category)
|
||||||
|
<a href="{{ route('news.category', $article->category->slug) }}" class="inline-flex items-center rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1.5 text-sky-200">{{ $article->category->name }}</a>
|
||||||
|
@endif
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1.5">
|
||||||
|
<i class="fa-regular fa-clock text-xs"></i>
|
||||||
|
{{ $article->reading_time }} min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- Cover image --}}
|
<div class="mx-auto max-w-7xl px-6 pt-8 pb-16 md:px-10">
|
||||||
@if($article->cover_url)
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<img src="{{ $article->cover_url }}" class="img-fluid rounded mb-4 w-100"
|
<article class="min-w-0">
|
||||||
alt="{{ $article->title }}" style="max-height:450px;object-fit:cover;">
|
@if($article->cover_url)
|
||||||
@endif
|
<div class="overflow-hidden rounded-[32px] border border-white/[0.06] bg-black/20 shadow-[0_24px_60px_rgba(0,0,0,0.24)]">
|
||||||
|
<img src="{{ $article->cover_url }}" alt="{{ $article->title }}" class="h-auto max-h-[520px] w-full object-cover">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Meta --}}
|
<div class="mt-6 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
|
||||||
<div class="d-flex align-items-center mb-3 text-muted small">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/45">
|
||||||
@if($article->category)
|
<span>{{ $article->author?->name ?? 'Skinbase' }}</span>
|
||||||
<a href="{{ route('news.category', $article->category->slug) }}"
|
|
||||||
class="badge badge-primary mr-2">{{ $article->category->name }}</a>
|
|
||||||
@endif
|
|
||||||
<span>{{ $article->author?->name }}</span>
|
|
||||||
<span class="mx-2">·</span>
|
|
||||||
<span>{{ $article->published_at?->format('d M Y') }}</span>
|
<span>{{ $article->published_at?->format('d M Y') }}</span>
|
||||||
<span class="mx-2">·</span>
|
<span>{{ number_format((int) $article->views) }} views</span>
|
||||||
<span><i class="fas fa-clock mr-1"></i>{{ $article->reading_time }} min read</span>
|
|
||||||
<span class="mx-2">·</span>
|
|
||||||
<span><i class="fas fa-eye mr-1"></i>{{ number_format($article->views) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="mb-3">{{ $article->title }}</h1>
|
|
||||||
|
|
||||||
@if($article->excerpt)
|
@if($article->excerpt)
|
||||||
<p class="lead text-muted mb-4">{{ $article->excerpt }}</p>
|
<p class="mt-5 text-lg leading-8 text-white/65">{{ $article->excerpt }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="news-content">
|
<div class="prose prose-invert prose-sky mt-8 max-w-none prose-p:text-white/72 prose-li:text-white/70 prose-strong:text-white prose-a:text-sky-300 hover:prose-a:text-sky-200 prose-headings:text-white">
|
||||||
{!! $article->content !!}
|
{!! $article->content !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Tags --}}
|
|
||||||
@if($article->tags->isNotEmpty())
|
@if($article->tags->isNotEmpty())
|
||||||
<div class="mt-4">
|
<div class="mt-8 flex flex-wrap gap-2 border-t border-white/[0.06] pt-6">
|
||||||
<strong><i class="fas fa-tags mr-1"></i></strong>
|
|
||||||
@foreach($article->tags as $tag)
|
@foreach($article->tags as $tag)
|
||||||
<a href="{{ route('news.tag', $tag->slug) }}"
|
<a href="{{ route('news.tag', $tag->slug) }}" class="inline-flex items-center rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/[0.14] hover:bg-white/[0.06] hover:text-white">#{{ $tag->name }}</a>
|
||||||
class="badge badge-secondary mr-1">{{ $tag->name }}</a>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Share buttons --}}
|
<div class="mt-8 flex flex-wrap items-center gap-3 border-t border-white/[0.06] pt-6">
|
||||||
<div class="mt-4 pt-4 border-top">
|
<span class="text-sm font-medium text-white/55">Share</span>
|
||||||
<strong>Share:</strong>
|
<a href="https://twitter.com/intent/tweet?url={{ urlencode(url()->current()) }}&text={{ urlencode($article->title) }}" class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-4 py-2 text-sm text-sky-200 transition hover:bg-sky-500/15" target="_blank" rel="noopener noreferrer">
|
||||||
<a href="https://twitter.com/intent/tweet?url={{ urlencode(url()->current()) }}&text={{ urlencode($article->title) }}"
|
<i class="fab fa-twitter text-xs"></i>
|
||||||
class="btn btn-sm btn-info ml-2" target="_blank" rel="noopener noreferrer">
|
Twitter
|
||||||
<i class="fab fa-twitter"></i> Twitter
|
|
||||||
</a>
|
</a>
|
||||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(url()->current()) }}"
|
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(url()->current()) }}" class="inline-flex items-center gap-2 rounded-full border border-blue-400/20 bg-blue-500/10 px-4 py-2 text-sm text-blue-200 transition hover:bg-blue-500/15" target="_blank" rel="noopener noreferrer">
|
||||||
class="btn btn-sm btn-primary ml-2" target="_blank" rel="noopener noreferrer">
|
<i class="fab fa-facebook text-xs"></i>
|
||||||
<i class="fab fa-facebook"></i> Facebook
|
Facebook
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Forum discussion link --}}
|
|
||||||
@if($article->forum_thread_id)
|
@if($article->forum_thread_id)
|
||||||
<div class="mt-4 alert alert-secondary">
|
<div class="mt-6 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-4 text-sm text-emerald-100/90">
|
||||||
<i class="fas fa-comments mr-2"></i>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<strong>Join the discussion:</strong>
|
<i class="fa-solid fa-comments text-xs"></i>
|
||||||
<a href="{{ url('/forum/thread/discussion-' . $article->slug) }}" class="ml-1">
|
<span class="font-medium">Join the discussion</span>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url('/forum/thread/discussion-' . $article->slug) }}" class="mt-2 inline-flex items-center gap-2 text-emerald-200 transition hover:text-white">
|
||||||
Discussion: {{ $article->title }}
|
Discussion: {{ $article->title }}
|
||||||
|
<i class="fa-solid fa-arrow-right text-[11px]"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Related articles --}}
|
@if($related->isNotEmpty())
|
||||||
@if($related->isNotEmpty())
|
<section class="mt-8">
|
||||||
<div class="mt-5">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<h4 class="mb-3">Related Articles</h4>
|
<h2 class="text-lg font-semibold text-white/90">Related Articles</h2>
|
||||||
<div class="row">
|
|
||||||
@foreach($related as $rel)
|
|
||||||
<div class="col-sm-6 mb-3">
|
|
||||||
@include('news._article_card', ['article' => $rel])
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
@foreach($related as $rel)
|
||||||
|
@include('news._article_card', ['article' => $rel])
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
|
</article>
|
||||||
|
|
||||||
</div>{{-- col-lg-8 --}}
|
<aside class="space-y-4">
|
||||||
|
@include('news._sidebar', ['categories' => $categories, 'trending' => $trending, 'tags' => $tags])
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
@extends('news.layout', [
|
@extends('news.layout', [
|
||||||
'metaTitle' => '#' . $tag->name . ' — News',
|
'metaTitle' => '#' . $tag->name . ' — News',
|
||||||
|
'metaDescription' => 'Announcements tagged with ' . $tag->name . '.',
|
||||||
|
'metaCanonical' => route('news.tag', $tag->slug),
|
||||||
])
|
])
|
||||||
|
|
||||||
@section('news_content')
|
@section('news_content')
|
||||||
<div class="container py-5">
|
@php
|
||||||
<h1 class="mb-4">
|
$headerBreadcrumbs = collect([
|
||||||
<i class="fas fa-tag mr-2"></i>#{{ $tag->name }}
|
(object) ['name' => 'Community', 'url' => route('community.activity')],
|
||||||
</h1>
|
(object) ['name' => 'Announcements', 'url' => route('news.index')],
|
||||||
|
(object) ['name' => '#' . $tag->name, 'url' => route('news.tag', $tag->slug)],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div class="row">
|
<x-nova-page-header
|
||||||
<div class="col-lg-8">
|
section="Community"
|
||||||
<div class="row">
|
:title="'#' . $tag->name"
|
||||||
@forelse($articles as $article)
|
icon="fa-tag"
|
||||||
<div class="col-sm-6 mb-4">
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
@include('news._article_card', ['article' => $article])
|
:description="'Stories and announcements tagged with #' . $tag->name . '.'"
|
||||||
|
headerClass="pb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-6 pt-8 pb-16 md:px-10">
|
||||||
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<section>
|
||||||
|
@if($articles->isEmpty())
|
||||||
|
<div class="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-8 py-14 text-center text-white/45">No articles are using this tag yet.</div>
|
||||||
|
@else
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
@foreach($articles as $article)
|
||||||
|
@include('news._article_card', ['article' => $article])
|
||||||
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@empty
|
<div class="mt-8 flex justify-center">{{ $articles->links() }}</div>
|
||||||
<div class="col-12 text-center text-muted py-5">No articles with this tag.</div>
|
@endif
|
||||||
@endforelse
|
</section>
|
||||||
</div>
|
<aside class="space-y-4">
|
||||||
<div class="mt-3">{{ $articles->links() }}</div>
|
@include('news._sidebar', ['categories' => $categories, 'trending' => $trending, 'tags' => $tags])
|
||||||
</div>
|
</aside>
|
||||||
<div class="col-lg-4">
|
|
||||||
@include('news._sidebar', ['categories' => $categories])
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -3,16 +3,29 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Hero header ── --}}
|
{{-- ── Hero header ── --}}
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
@php
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
$headerBreadcrumbs = collect([
|
||||||
<div>
|
(object) ['name' => 'Creators', 'url' => '/creators/top'],
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
(object) ['name' => 'Top Creators', 'url' => route('creators.top')],
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">Top Authors</h1>
|
]);
|
||||||
<p class="mt-1 text-sm text-white/50">Most popular members ranked by artwork {{ $metric === 'downloads' ? 'downloads' : 'views' }}.</p>
|
@endphp
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Metric switcher --}}
|
<x-nova-page-header
|
||||||
<nav class="flex items-center gap-2" aria-label="Ranking metric">
|
section="Creators"
|
||||||
|
title="Top Creators"
|
||||||
|
icon="fa-star"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
:description="'Most popular creators ranked by artwork ' . ($metric === 'downloads' ? 'downloads' : 'views') . '.'"
|
||||||
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<a href="{{ route('creators.rising') }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||||
|
<i class="fa-solid fa-arrow-trend-up text-xs"></i>
|
||||||
|
Rising Creators
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap items-center gap-2" aria-label="Ranking metric">
|
||||||
<a href="{{ request()->fullUrlWithQuery(['metric' => 'views']) }}"
|
<a href="{{ request()->fullUrlWithQuery(['metric' => 'views']) }}"
|
||||||
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
|
class="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-xs font-medium border transition-colors
|
||||||
{{ $metric === 'views' ? 'bg-sky-500/15 text-sky-300 border-sky-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
|
{{ $metric === 'views' ? 'bg-sky-500/15 text-sky-300 border-sky-500/30' : 'border-white/[0.08] bg-white/[0.04] text-white/55 hover:text-white hover:bg-white/[0.08]' }}">
|
||||||
@@ -31,11 +44,11 @@
|
|||||||
Downloads
|
Downloads
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- ── Leaderboard ── --}}
|
{{-- ── Leaderboard ── --}}
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pt-8 pb-16 md:px-10">
|
||||||
@php
|
@php
|
||||||
$offset = ($authors->currentPage() - 1) * $authors->perPage();
|
$offset = ($authors->currentPage() - 1) * $authors->perPage();
|
||||||
$isFirstPage = $authors->currentPage() === 1;
|
$isFirstPage = $authors->currentPage() === 1;
|
||||||
@@ -73,15 +86,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<img src="{{ $avatarUrl }}" alt="{{ $author->uname }}"
|
<img src="{{ $avatarUrl }}" alt="{{ $author->uname }}"
|
||||||
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]">
|
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-base font-semibold text-white">{{ $author->uname ?? 'Unknown' }}</div>
|
<div class="truncate text-base font-semibold text-white">{{ $author->uname ?? 'Unknown' }}</div>
|
||||||
@if (!empty($author->username))
|
@if (!empty($author->username))
|
||||||
<div class="truncate text-xs text-white/40">{{ '@' . $author->username }}</div>
|
<div class="truncate text-xs text-white/40">{{ '@' . $author->username }}</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="mt-1 text-lg font-bold {{ $metric === 'downloads' ? 'text-emerald-400' : 'text-sky-400' }}">
|
</div>
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<div class="text-lg font-bold {{ $metric === 'downloads' ? 'text-emerald-400' : 'text-sky-400' }}">
|
||||||
{{ number_format($author->total ?? 0) }}
|
{{ number_format($author->total ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,5 +77,4 @@
|
|||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script src="/js/legacy-gallery-init.js"></script>
|
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title ?? 'Monthly Top Commentators', 'url' => route('legacy.monthly_commentators')],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
{{-- ── Hero header ── --}}
|
@section('content')
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
<x-nova-page-header
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
section="Community"
|
||||||
<div>
|
:title="$page_title ?? 'Monthly Top Commentators'"
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
icon="fa-ranking-star"
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">Monthly Top Commentators</h1>
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
<p class="mt-1 text-sm text-white/50">Members who posted the most comments in the last 30 days.</p>
|
description="Members who posted the most comments in the last 30 days."
|
||||||
</div>
|
headerClass="pb-6"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
<span class="flex-shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-violet-500/10 text-violet-300 ring-1 ring-violet-500/25">
|
<span class="flex-shrink-0 inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-violet-500/10 text-violet-300 ring-1 ring-violet-500/25">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Last 30 days
|
Last 30 days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- ── Leaderboard ── --}}
|
{{-- ── Leaderboard ── --}}
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pt-8 pb-16 md:px-10">
|
||||||
@php
|
@php
|
||||||
$offset = ($rows->currentPage() - 1) * $rows->perPage();
|
$offset = ($rows->currentPage() - 1) * $rows->perPage();
|
||||||
$isFirstPage = $rows->currentPage() === 1;
|
$isFirstPage = $rows->currentPage() === 1;
|
||||||
|
|||||||
@@ -2,25 +2,30 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
@php
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
|
$headerBreadcrumbs = collect([
|
||||||
<div>
|
(object) ['name' => 'Creators', 'url' => '/creators/top'],
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Creators</p>
|
(object) ['name' => 'Rising Creators', 'url' => route('creators.rising')],
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
]);
|
||||||
<i class="fa-solid fa-arrow-trend-up text-sky-400 text-2xl"></i>
|
@endphp
|
||||||
Rising Creators
|
|
||||||
</h1>
|
<x-nova-page-header
|
||||||
<p class="mt-1 text-sm text-white/50">Creators gaining momentum with the most views over the last 90 days.</p>
|
section="Creators"
|
||||||
</div>
|
title="Rising Creators"
|
||||||
|
icon="fa-arrow-trend-up"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
description="Creators gaining momentum with the most views over the last 90 days."
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
<a href="{{ route('creators.top') }}"
|
<a href="{{ route('creators.top') }}"
|
||||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||||
<i class="fa-solid fa-star text-xs"></i>
|
<i class="fa-solid fa-star text-xs"></i>
|
||||||
Top Creators
|
Top Creators
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
<div class="px-6 pb-16 md:px-10">
|
<div class="px-6 pt-8 pb-16 md:px-10">
|
||||||
@php
|
@php
|
||||||
$offset = ($creators->currentPage() - 1) * $creators->perPage();
|
$offset = ($creators->currentPage() - 1) * $creators->perPage();
|
||||||
$isFirstPage = $creators->currentPage() === 1;
|
$isFirstPage = $creators->currentPage() === 1;
|
||||||
@@ -56,16 +61,18 @@
|
|||||||
<span class="text-xs font-semibold uppercase tracking-widest text-sky-300/80">Recent Views</span>
|
<span class="text-xs font-semibold uppercase tracking-widest text-sky-300/80">Recent Views</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
<img src="{{ $avatarUrl }}" alt="{{ $creator->uname }}"
|
||||||
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]"
|
class="w-14 h-14 rounded-full object-cover ring-1 ring-white/[0.12]"
|
||||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-base font-semibold text-white">{{ $creator->uname ?? 'Unknown' }}</div>
|
<div class="truncate text-base font-semibold text-white">{{ $creator->uname ?? 'Unknown' }}</div>
|
||||||
@if($creator->username ?? null)
|
@if($creator->username ?? null)
|
||||||
<div class="truncate text-xs text-white/40">{{ '@' . $creator->username }}</div>
|
<div class="truncate text-xs text-white/40">{{ '@' . $creator->username }}</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="mt-1 text-lg font-bold text-sky-400">{{ number_format($creator->total ?? 0) }}</div>
|
</div>
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<div class="text-lg font-bold text-sky-400">{{ number_format($creator->total ?? 0) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title ?? 'Daily Uploads', 'url' => route('legacy.daily_uploads')],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Hero header ── --}}
|
<x-nova-page-header
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
section="Uploads"
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
:title="$page_title ?? 'Daily Uploads'"
|
||||||
<div>
|
icon="fa-calendar-day"
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Skinbase</p>
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">Daily Uploads</h1>
|
description="Browse all artworks uploaded on a specific date."
|
||||||
<p class="mt-1 text-sm text-white/50">Browse all artworks uploaded on a specific date.</p>
|
headerClass="pb-6"
|
||||||
</div>
|
>
|
||||||
<a href="{{ route('uploads.latest') }}"
|
<x-slot name="actions">
|
||||||
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
<a
|
||||||
|
href="{{ route('uploads.latest') }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Latest Uploads
|
Latest Uploads
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- ── Date strip ── --}}
|
{{-- ── Date strip ── --}}
|
||||||
<div class="px-6 md:px-10 pb-5">
|
<div class="px-6 pt-8 md:px-10 pb-5">
|
||||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none" id="dateStrip">
|
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none" id="dateStrip">
|
||||||
@foreach($dates as $i => $d)
|
@foreach($dates as $i => $d)
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$discoverBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Discover', 'url' => '/discover/trending'],
|
||||||
|
(object) ['name' => 'For You', 'url' => '/discover/for-you'],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Hero header ── --}}
|
<x-nova-page-header
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
section="Discover"
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
title="For You"
|
||||||
<div>
|
icon="fa-wand-magic-sparkles"
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
:breadcrumbs="$discoverBreadcrumbs"
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
description="Artworks picked for you based on your taste."
|
||||||
<i class="fa-solid fa-wand-magic-sparkles text-yellow-400 text-2xl"></i>
|
headerClass="pb-6"
|
||||||
For You
|
actionsClass="lg:pt-8"
|
||||||
</h1>
|
iconClass="text-yellow-400"
|
||||||
<p class="mt-1 text-sm text-white/50">Artworks picked for you based on your taste.</p>
|
>
|
||||||
</div>
|
<x-slot name="actions">
|
||||||
|
|
||||||
{{-- Section switcher pills --}}
|
|
||||||
@include('web.discover._nav', ['section' => 'for-you'])
|
@include('web.discover._nav', ['section' => 'for-you'])
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||||
@php
|
@php
|
||||||
@@ -36,15 +41,17 @@
|
|||||||
'height' => $art->height ?? null,
|
'height' => $art->height ?? null,
|
||||||
])->values();
|
])->values();
|
||||||
@endphp
|
@endphp
|
||||||
<div
|
<section class="px-6 pt-8 md:px-10">
|
||||||
data-react-masonry-gallery
|
<div
|
||||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
data-react-masonry-gallery
|
||||||
data-gallery-type="for-you"
|
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||||
data-cursor-endpoint="{{ route('discover.for-you') }}"
|
data-gallery-type="for-you"
|
||||||
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
|
data-cursor-endpoint="{{ route('discover.for-you') }}"
|
||||||
data-limit="40"
|
@if (!empty($next_cursor)) data-next-cursor="{{ $next_cursor }}" @endif
|
||||||
class="min-h-32"
|
data-limit="40"
|
||||||
></div>
|
class="min-h-32"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$discoverBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Discover', 'url' => '/discover/trending'],
|
||||||
|
(object) ['name' => $page_title ?? 'Discover', 'url' => request()->path()],
|
||||||
|
]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
|
||||||
{{-- ── Hero header ── --}}
|
<x-nova-page-header
|
||||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
section="Discover"
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
:title="$page_title ?? 'Discover'"
|
||||||
<div>
|
:icon="$icon ?? 'fa-compass'"
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
:breadcrumbs="$discoverBreadcrumbs"
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
:description="$description ?? null"
|
||||||
<i class="fa-solid {{ $icon ?? 'fa-compass' }} text-sky-400 text-2xl"></i>
|
headerClass="pb-6"
|
||||||
{{ $page_title ?? 'Discover' }}
|
actionsClass="lg:pt-8"
|
||||||
</h1>
|
>
|
||||||
@isset($description)
|
<x-slot name="actions">
|
||||||
<p class="mt-1 text-sm text-white/50">{{ $description }}</p>
|
|
||||||
@endisset
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Section switcher pills --}}
|
|
||||||
@include('web.discover._nav', ['section' => $section ?? ''])
|
@include('web.discover._nav', ['section' => $section ?? ''])
|
||||||
</div>
|
</x-slot>
|
||||||
</div>
|
</x-nova-page-header>
|
||||||
|
|
||||||
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
{{-- ── Artwork grid (React MasonryGallery) ── --}}
|
||||||
@php
|
@php
|
||||||
@@ -39,14 +41,16 @@
|
|||||||
])->values();
|
])->values();
|
||||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||||
@endphp
|
@endphp
|
||||||
<div
|
<section class="px-6 pt-8 md:px-10">
|
||||||
data-react-masonry-gallery
|
<div
|
||||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
data-react-masonry-gallery
|
||||||
data-gallery-type="{{ $section ?? 'discover' }}"
|
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
data-gallery-type="{{ $section ?? 'discover' }}"
|
||||||
data-limit="24"
|
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||||
class="min-h-32"
|
data-limit="24"
|
||||||
></div>
|
class="min-h-32"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,84 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
|
@php
|
||||||
|
$featuredBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Featured', 'url' => request()->path()],
|
||||||
|
(object) ['name' => $page_title ?? 'Featured Artworks', 'url' => request()->fullUrl()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
|
||||||
|
'id' => $art->id,
|
||||||
|
'name' => $art->name ?? null,
|
||||||
|
'slug' => $art->slug ?? null,
|
||||||
|
'url' => $art->url ?? null,
|
||||||
|
'thumb' => $art->thumb_url ?? null,
|
||||||
|
'thumb_url' => $art->thumb_url ?? null,
|
||||||
|
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||||
|
'uname' => $art->uname ?? '',
|
||||||
|
'username' => $art->username ?? $art->uname ?? '',
|
||||||
|
'avatar_url' => $art->avatar_url ?? null,
|
||||||
|
'category_name' => $art->category_name ?? '',
|
||||||
|
'category_slug' => $art->category_slug ?? '',
|
||||||
|
'width' => $art->width ?? null,
|
||||||
|
'height' => $art->height ?? null,
|
||||||
|
])->values();
|
||||||
|
|
||||||
|
$galleryNextPageUrl = $artworks->nextPageUrl();
|
||||||
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-fluid legacy-page">
|
<x-nova-page-header
|
||||||
<div class="effect2 page-header-wrap">
|
section="Featured"
|
||||||
<header class="page-heading">
|
:title="$page_title ?? 'Featured Artworks'"
|
||||||
<h1 class="page-header">{{ $page_title ?? 'Featured Artworks' }}</h1>
|
icon="fa-star"
|
||||||
</header>
|
:breadcrumbs="$featuredBreadcrumbs"
|
||||||
|
description="Browse staff-picked and community-highlighted artwork in the shared gallery feed."
|
||||||
<div class="mb-3">
|
headerClass="pb-6"
|
||||||
<strong>Show:</strong>
|
actionsClass="lg:pt-8"
|
||||||
<ul id="recentTab" class="list-inline">
|
>
|
||||||
@foreach($artworkTypes as $k => $label)
|
<x-slot name="actions">
|
||||||
<li class="list-inline-item">
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<a href="/featured-artworks?type={{ (int)$k }}" @if((int)$k === (int)$type) class="active" @endif>{{ $label }}</a>
|
@foreach($artworkTypes as $k => $label)
|
||||||
</li>
|
<a
|
||||||
@endforeach
|
href="{{ url()->current() }}?type={{ (int) $k }}"
|
||||||
</ul>
|
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {{ (int) $k === (int) $type ? 'bg-amber-500/20 text-amber-200 ring-1 ring-amber-400/30' : 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}"
|
||||||
<br style="clear:both">
|
>
|
||||||
</div>
|
{{ $label }}
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
@if($artworks)
|
|
||||||
<div class="container_photo gallery_box">
|
|
||||||
@foreach($artworks as $art)
|
|
||||||
@php
|
|
||||||
$card = (object) [
|
|
||||||
'url' => url('/art/' . ($art->id ?? '') . '/' . \Illuminate\Support\Str::slug($art->name ?? '')),
|
|
||||||
'thumb' => $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp',
|
|
||||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
|
||||||
'name' => $art->name ?? '',
|
|
||||||
'uname' => $art->uname ?? 'Unknown',
|
|
||||||
'gid_num' => $art->gid_num ?? 0,
|
|
||||||
'category_name' => $art->category_name ?? '',
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
@include('web.partials._artwork_card', ['art' => $card])
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
</x-slot>
|
||||||
<p class="text-muted">No artworks found.</p>
|
</x-nova-page-header>
|
||||||
@endif
|
|
||||||
|
|
||||||
|
<section class="px-6 pt-8 md:px-10">
|
||||||
<div class="paginationMenu text-center">
|
<div
|
||||||
@if($artworks){{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}@endif
|
data-react-masonry-gallery
|
||||||
</div>
|
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||||
</div>
|
data-gallery-type="featured"
|
||||||
|
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||||
|
data-limit="39"
|
||||||
|
class="min-h-32"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (min-width: 2600px) {
|
||||||
|
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||||
|
@endpush
|
||||||
|
|||||||
@@ -1,31 +1,63 @@
|
|||||||
@extends('layouts.nova.content-layout')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@php
|
@push('head')
|
||||||
$hero_title = 'My Stories';
|
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||||
$hero_description = 'Drafts, published stories, and archived work in one creator dashboard.';
|
@endpush
|
||||||
@endphp
|
|
||||||
|
|
||||||
@section('page-content')
|
@section('content')
|
||||||
<div class="space-y-8">
|
<div class="container-fluid legacy-page">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
|
<div class="pt-0">
|
||||||
<div>
|
<div class="mx-auto w-full">
|
||||||
<h2 class="text-lg font-semibold text-white">Creator Story Dashboard</h2>
|
<div class="relative min-h-[calc(100vh-64px)]">
|
||||||
<p class="text-sm text-gray-300">Write, schedule, review, and track your stories.</p>
|
<main class="w-full">
|
||||||
</div>
|
<x-nova-page-header
|
||||||
<a href="{{ route('creator.stories.create') }}" class="rounded-xl border border-sky-400/40 bg-sky-500/15 px-4 py-2 text-sm font-semibold text-sky-200 transition hover:scale-[1.02] hover:bg-sky-500/25">Write Story</a>
|
section="Creator"
|
||||||
</div>
|
title="My Stories"
|
||||||
|
icon="fa-pen-nib"
|
||||||
|
:breadcrumbs="collect([
|
||||||
|
(object) ['name' => 'Creator', 'url' => route('creator.stories.index')],
|
||||||
|
(object) ['name' => 'My Stories', 'url' => route('creator.stories.index')],
|
||||||
|
])"
|
||||||
|
description="Drafts, published stories, and archived work in one creator dashboard."
|
||||||
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<a href="{{ route('creator.stories.create') }}"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white">
|
||||||
|
<i class="fa-solid fa-pen text-xs"></i>
|
||||||
|
Write Story
|
||||||
|
</a>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
|
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||||
|
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/35">Drafts</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($drafts->count()) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/35">Published</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($publishedStories->count()) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-white/[0.08] bg-white/[0.03] p-4 sm:col-span-2 xl:col-span-1">
|
||||||
|
<p class="text-xs uppercase tracking-widest text-white/35">Archived</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($archivedStories->count()) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<section class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5 shadow-lg">
|
||||||
<h3 class="mb-4 text-base font-semibold text-white">Drafts</h3>
|
<h3 class="mb-4 text-base font-semibold text-white">Drafts</h3>
|
||||||
@if($drafts->isEmpty())
|
@if($drafts->isEmpty())
|
||||||
<p class="text-sm text-gray-400">No drafts yet.</p>
|
<p class="text-sm text-gray-400">No drafts yet.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
@foreach($drafts as $story)
|
@foreach($drafts as $story)
|
||||||
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
|
<article class="rounded-xl border border-white/[0.06] bg-black/20 p-4 transition hover:bg-white/[0.03]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
||||||
<span class="rounded-full border border-gray-600 px-2 py-1 text-xs uppercase tracking-wide text-gray-300">{{ str_replace('_', ' ', $story->status) }}</span>
|
<span class="rounded-full border border-white/[0.08] px-2 py-1 text-xs uppercase tracking-wide text-white/55">{{ str_replace('_', ' ', $story->status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-400">Last edited {{ optional($story->updated_at)->diffForHumans() }}</p>
|
<p class="mt-2 text-xs text-gray-400">Last edited {{ optional($story->updated_at)->diffForHumans() }}</p>
|
||||||
@if($story->rejected_reason)
|
@if($story->rejected_reason)
|
||||||
@@ -45,14 +77,14 @@
|
|||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
|
<section class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5 shadow-lg">
|
||||||
<h3 class="mb-4 text-base font-semibold text-white">Published Stories</h3>
|
<h3 class="mb-4 text-base font-semibold text-white">Published Stories</h3>
|
||||||
@if($publishedStories->isEmpty())
|
@if($publishedStories->isEmpty())
|
||||||
<p class="text-sm text-gray-400">No published stories yet.</p>
|
<p class="text-sm text-gray-400">No published stories yet.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
@foreach($publishedStories as $story)
|
@foreach($publishedStories as $story)
|
||||||
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
|
<article class="rounded-xl border border-white/[0.06] bg-black/20 p-4 transition hover:bg-white/[0.03]">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
||||||
<span class="rounded-full border border-emerald-500/40 px-2 py-1 text-xs uppercase tracking-wide text-emerald-200">{{ str_replace('_', ' ', $story->status) }}</span>
|
<span class="rounded-full border border-emerald-500/40 px-2 py-1 text-xs uppercase tracking-wide text-emerald-200">{{ str_replace('_', ' ', $story->status) }}</span>
|
||||||
@@ -69,14 +101,14 @@
|
|||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
|
<section class="rounded-xl border border-white/[0.06] bg-white/[0.02] p-5 shadow-lg">
|
||||||
<h3 class="mb-4 text-base font-semibold text-white">Archived Stories</h3>
|
<h3 class="mb-4 text-base font-semibold text-white">Archived Stories</h3>
|
||||||
@if($archivedStories->isEmpty())
|
@if($archivedStories->isEmpty())
|
||||||
<p class="text-sm text-gray-400">No archived stories.</p>
|
<p class="text-sm text-gray-400">No archived stories.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
@foreach($archivedStories as $story)
|
@foreach($archivedStories as $story)
|
||||||
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
|
<article class="rounded-xl border border-white/[0.06] bg-black/20 p-4 transition hover:bg-white/[0.03]">
|
||||||
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
|
||||||
<p class="mt-2 text-xs text-gray-400">Archived {{ optional($story->updated_at)->diffForHumans() }}</p>
|
<p class="mt-2 text-xs text-gray-400">Archived {{ optional($story->updated_at)->diffForHumans() }}</p>
|
||||||
<div class="mt-3 flex flex-wrap gap-3 text-xs">
|
<div class="mt-3 flex flex-wrap gap-3 text-xs">
|
||||||
@@ -87,5 +119,11 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -11,21 +11,23 @@
|
|||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="relative overflow-hidden nb-hero-radial border-b border-white/10">
|
<x-nova-page-header
|
||||||
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
|
section="Stories"
|
||||||
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
|
title="Browse Stories"
|
||||||
|
icon="fa-newspaper"
|
||||||
<div class="relative px-6 py-12 md:px-10 md:py-14">
|
:breadcrumbs="$breadcrumbs ?? collect()"
|
||||||
<div class="mt-2 py-4">
|
description="List of all published stories across tutorials, creator journals, interviews, and project breakdowns."
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-4">Browse</p>
|
contentClass="max-w-3xl"
|
||||||
<h1 class="text-4xl md:text-5xl font-bold tracking-tight text-white/95 leading-tight flex items-center gap-3">
|
actionsClass="lg:pt-8"
|
||||||
<i class="fa-solid fa-newspaper text-sky-400 text-3xl"></i>
|
>
|
||||||
Browse Stories
|
<x-slot name="actions">
|
||||||
</h1>
|
<a href="{{ route('creator.stories.create') }}"
|
||||||
<p class="mt-3 text-base text-neutral-400 max-w-3xl">List of all published stories across tutorials, creator journals, interviews, and project breakdowns.</p>
|
class="inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white transition-colors">
|
||||||
</div>
|
<i class="fa-solid fa-pen-nib text-xs"></i>
|
||||||
</div>
|
Write Story
|
||||||
</div>
|
</a>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
<div class="border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
|
<div class="border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
|
||||||
<div class="px-6 md:px-10">
|
<div class="px-6 md:px-10">
|
||||||
|
|||||||
@@ -12,14 +12,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Web\ArtController;
|
//use App\Http\Controllers\Web\ArtController;
|
||||||
use App\Http\Controllers\Misc\AvatarController as LegacyAvatarController;
|
use App\Http\Controllers\Legacy\AvatarController;
|
||||||
use App\Http\Controllers\Web\CategoryController;
|
use App\Http\Controllers\Web\CategoryController;
|
||||||
use App\Http\Controllers\Web\FeaturedArtworksController;
|
use App\Http\Controllers\Web\FeaturedArtworksController;
|
||||||
use App\Http\Controllers\Web\DailyUploadsController;
|
use App\Http\Controllers\Web\DailyUploadsController;
|
||||||
use App\Http\Controllers\Community\ChatController;
|
use App\Http\Controllers\Community\ChatController;
|
||||||
use App\Http\Controllers\Community\LatestController;
|
use App\Http\Controllers\Community\LatestController;
|
||||||
use App\Http\Controllers\Community\LatestCommentsController;
|
|
||||||
use App\Http\Controllers\User\TopFavouritesController;
|
use App\Http\Controllers\User\TopFavouritesController;
|
||||||
use App\Http\Controllers\User\FavouritesController;
|
use App\Http\Controllers\User\FavouritesController;
|
||||||
use App\Http\Controllers\User\TopAuthorsController;
|
use App\Http\Controllers\User\TopAuthorsController;
|
||||||
@@ -34,16 +33,15 @@ use App\Http\Controllers\Web\BrowseGalleryController;
|
|||||||
use App\Http\Controllers\Web\GalleryController;
|
use App\Http\Controllers\Web\GalleryController;
|
||||||
use App\Http\Controllers\Web\RssFeedController;
|
use App\Http\Controllers\Web\RssFeedController;
|
||||||
//use App\Http\Controllers\Dashboard\ManageController;
|
//use App\Http\Controllers\Dashboard\ManageController;
|
||||||
//use App\Http\Controllers\User\ReceivedCommentsController;
|
use App\Http\Controllers\Legacy\ReceivedCommentsController;
|
||||||
|
|
||||||
// ── AVATARS ───────────────────────────────────────────────────────────────────
|
// ── AVATARS ───────────────────────────────────────────────────────────────────
|
||||||
Route::get('/avatar/{id}/{name?}', [LegacyAvatarController::class, 'show'])
|
Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show'])
|
||||||
->where('id', '\d+')
|
->where('id', '\d+')
|
||||||
->name('legacy.avatar');
|
->name('legacy.avatar');
|
||||||
|
|
||||||
// ── ARTWORK (legacy comment URL) ──────────────────────────────────────────────
|
// ── ARTWORK (legacy comment URL) ──────────────────────────────────────────────
|
||||||
Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])
|
//Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+');
|
||||||
->where('id', '\d+');
|
|
||||||
|
|
||||||
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
// ── CATEGORIES / SECTIONS ─────────────────────────────────────────────────────
|
||||||
Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories');
|
Route::get('/categories', [CategoryController::class, 'index'])->name('legacy.categories');
|
||||||
@@ -55,29 +53,31 @@ Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, '
|
|||||||
->name('legacy.category');
|
->name('legacy.category');
|
||||||
|
|
||||||
// ── BROWSE / FEATURED / DAILY ─────────────────────────────────────────────────
|
// ── BROWSE / FEATURED / DAILY ─────────────────────────────────────────────────
|
||||||
Route::get('/browse', [BrowseGalleryController::class, 'browse'])->name('legacy.browse');
|
//Route::get('/browse', [BrowseGalleryController::class, 'browse'])->name('legacy.browse');
|
||||||
//Route::get('/browse', fn () => redirect('/explore', 301))->name('legacy.browse');
|
Route::get('/browse', fn () => redirect('/explore', 301))->name('legacy.browse');
|
||||||
Route::get('/browse-redirect', fn () => redirect('/explore', 301))->name('legacy.browse.redirect');
|
|
||||||
Route::get('/wallpapers-redirect', fn () => redirect('/explore/wallpapers', 301))->name('legacy.wallpapers.redirect');
|
|
||||||
Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured');
|
Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured');
|
||||||
Route::get('/featured-artworks',[FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks');
|
Route::get('/featured-artworks',[FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks');
|
||||||
Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads');
|
Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads');
|
||||||
|
|
||||||
// ── CHAT ──────────────────────────────────────────────────────────────────────
|
// ── CHAT ──────────────────────────────────────────────────────────────────────
|
||||||
Route::get('/chat', [ChatController::class, 'index'])->name('legacy.chat');
|
Route::get('/chat', fn () => redirect()->route('community.chat', [], 301))->name('legacy.chat');
|
||||||
Route::post('/chat_post', [ChatController::class, 'post'])->name('legacy.chat.post');
|
Route::post('/chat_post', [ChatController::class, 'post'])->name('legacy.chat.post');
|
||||||
|
|
||||||
// ── UPLOADS / COMMENTS / DOWNLOADS (SEO alias pages) ─────────────────────────
|
// ── UPLOADS / COMMENTS / DOWNLOADS (SEO alias pages) ─────────────────────────
|
||||||
Route::get('/uploads/latest', [LatestController::class, 'index'])->name('uploads.latest');
|
Route::get('/uploads/latest', [LatestController::class, 'index'])->name('uploads.latest');
|
||||||
Route::get('/uploads/daily', [DailyUploadsController::class, 'index'])->name('uploads.daily');
|
Route::get('/uploads/daily', [DailyUploadsController::class, 'index'])->name('uploads.daily');
|
||||||
Route::get('/members/photos', [MembersController::class, 'photos'])->name('members.photos');
|
Route::get('/members/photos', [MembersController::class, 'photos'])->name('members.photos');
|
||||||
Route::get('/authors/top', [TopAuthorsController::class, 'index'])->name('authors.top');
|
Route::get('/authors/top', [TopAuthorsController::class, 'index'])->name('authors.top');
|
||||||
Route::get('/comments/latest', [LatestCommentsController::class, 'index'])->name('comments.latest');
|
Route::get('/comments/latest', function () {
|
||||||
|
return redirect()->route('community.activity', request()->query(), 301);
|
||||||
|
})->name('comments.latest');
|
||||||
Route::get('/comments/monthly', [MonthlyCommentatorsController::class, 'index'])->name('comments.monthly');
|
Route::get('/comments/monthly', [MonthlyCommentatorsController::class, 'index'])->name('comments.monthly');
|
||||||
Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->name('downloads.today');
|
Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->name('downloads.today');
|
||||||
|
|
||||||
Route::get('/latest', [LatestController::class, 'index'])->name('legacy.latest');
|
Route::get('/latest', [LatestController::class, 'index'])->name('legacy.latest');
|
||||||
Route::get('/latest-comments', [LatestCommentsController::class, 'index'])->name('legacy.latest_comments');
|
Route::get('/latest-comments', function () {
|
||||||
|
return redirect()->route('community.activity', request()->query(), 301);
|
||||||
|
})->name('legacy.latest_comments');
|
||||||
Route::get('/today-in-history', [TodayInHistoryController::class, 'index'])->name('legacy.today_in_history');
|
Route::get('/today-in-history', [TodayInHistoryController::class, 'index'])->name('legacy.today_in_history');
|
||||||
Route::get('/today-downloads', [TodayDownloadsController::class, 'index'])->name('legacy.today_downloads');
|
Route::get('/today-downloads', [TodayDownloadsController::class, 'index'])->name('legacy.today_downloads');
|
||||||
Route::get('/monthly-commentators', [MonthlyCommentatorsController::class, 'index'])->name('legacy.monthly_commentators');
|
Route::get('/monthly-commentators', [MonthlyCommentatorsController::class, 'index'])->name('legacy.monthly_commentators');
|
||||||
@@ -104,7 +104,7 @@ Route::post('/favourites/{userId}/delete/{artworkId}', [FavouritesController::cl
|
|||||||
|
|
||||||
Route::middleware('ensure.onboarding.complete')
|
Route::middleware('ensure.onboarding.complete')
|
||||||
->get('/gallery/{id}/{username?}', [GalleryController::class, 'show'])
|
->get('/gallery/{id}/{username?}', [GalleryController::class, 'show'])
|
||||||
->name('legacy.gallery');
|
->name('legacy.gallery'); // We need to fix to a new gallery
|
||||||
|
|
||||||
// ── PROFILE (legacy URL patterns) ────────────────────────────────────────────
|
// ── PROFILE (legacy URL patterns) ────────────────────────────────────────────
|
||||||
Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])
|
Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])
|
||||||
@@ -125,7 +125,7 @@ Route::middleware(['auth'])->match(['get','post'], '/user', function () {
|
|||||||
})->name('legacy.user.redirect');
|
})->name('legacy.user.redirect');
|
||||||
|
|
||||||
// ── COMMENTS / STATISTICS ─────────────────────────────────────────────────────
|
// ── COMMENTS / STATISTICS ─────────────────────────────────────────────────────
|
||||||
//Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController::class, 'index'])->name('legacy.received_comments');
|
Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController::class, 'index'])->name('legacy.received_comments');
|
||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::get('/statistics', [StatisticsController::class, 'index'])->name('legacy.statistics');
|
Route::get('/statistics', [StatisticsController::class, 'index'])->name('legacy.statistics');
|
||||||
|
|||||||
Reference in New Issue
Block a user