Skip to content
Merged

Dev #16

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 80,
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx",
"cn",
"cva"
],
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx", "cn", "cva"],
"tailwindStylesheet": "resources/css/app.css",
"tabWidth": 4,
"overrides": [
Expand Down
43 changes: 43 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,53 @@ public function share(Request $request): array
return [
...parent::share($request),
'name' => config('app.name'),
'seo' => $this->seo($request),
'auth' => [
'user' => $request->user(),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}

/**
* @return array<string, string>
*/
private function seo(Request $request): array
{
$appName = (string) config('app.name', 'applyst');
$title = 'Track your job applications effectively';
$description = 'Track job applications, monitor job search effectiveness, and keep follow-ups organized in one place.';
$robots = 'index, follow';
$ogImageVersion = file_exists(public_path('og.png')) ? filemtime(public_path('og.png')) : time();

if ($request->routeIs('home')) {
$title = 'Track your job applications effectively';
$description = 'Track job applications, monitor your job search effectiveness, and keep follow-ups organized in one clean dashboard.';
}

if (
$request->routeIs('dashboard')
|| $request->routeIs('job-applications.*')
|| $request->routeIs('settings.*')
|| $request->routeIs('login')
|| $request->routeIs('register')
|| $request->routeIs('password.*')
|| $request->routeIs('verification.*')
|| $request->routeIs('two-factor.*')
|| $request->routeIs('password.confirm')
) {
$robots = 'noindex, nofollow';
}

return [
'title' => $title,
'fullTitle' => $title.' - '.$appName,
'description' => $description,
'url' => $request->url(),
'type' => 'website',
'image' => asset('og.png').'?v='.$ogImageVersion,
'siteName' => $appName,
'robots' => $robots,
];
}
}
Binary file added public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
102 changes: 102 additions & 0 deletions resources/js/components/shared/seo-head.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Head } from '@inertiajs/react';

type SeoHeadProps = {
title: string;
description?: string;
image?: string;
url?: string;
type?: 'website' | 'article';
robots?: string;
};

const appName = import.meta.env.VITE_APP_NAME || 'applyst';
const defaultDescription =
'Track job applications, monitor job search effectiveness, and keep follow-ups organized in one place.';

function toAbsoluteUrl(value: string): string {
if (/^https?:\/\//i.test(value)) {
return value;
}

if (typeof window === 'undefined') {
return value;
}

return new URL(value, window.location.origin).toString();
}

export function SeoHead({
title,
description = defaultDescription,
image = '/og.png',
url,
type = 'website',
robots = 'index, follow',
}: SeoHeadProps) {
const fullTitle = `${title} - ${appName}`;
const canonicalUrl =
url ??
(typeof window === 'undefined' ? undefined : window.location.href);
const imageUrl = toAbsoluteUrl(image);

return (
<Head title={title}>
<meta
head-key="description"
name="description"
content={description}
/>
<meta head-key="robots" name="robots" content={robots} />

{canonicalUrl ? (
<link
head-key="canonical"
rel="canonical"
href={canonicalUrl}
/>
) : null}

<meta head-key="og:type" property="og:type" content={type} />
<meta head-key="og:title" property="og:title" content={fullTitle} />
<meta
head-key="og:description"
property="og:description"
content={description}
/>
{canonicalUrl ? (
<meta
head-key="og:url"
property="og:url"
content={canonicalUrl}
/>
) : null}
<meta head-key="og:image" property="og:image" content={imageUrl} />
<meta
head-key="og:site_name"
property="og:site_name"
content={appName}
/>

<meta
head-key="twitter:card"
name="twitter:card"
content="summary_large_image"
/>
<meta
head-key="twitter:title"
name="twitter:title"
content={fullTitle}
/>
<meta
head-key="twitter:description"
name="twitter:description"
content={description}
/>
<meta
head-key="twitter:image"
name="twitter:image"
content={imageUrl}
/>
</Head>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IconSvgElement } from '@hugeicons/react';
import { HugeiconsIcon } from '@hugeicons/react';
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Card,
Expand All @@ -9,26 +10,68 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
JobApplicationStatusBadge,
getJobApplicationStatusMeta,
} from '@/features/job-applications/components/job-application-status';
import type { JobApplicationStatusLog } from '@/features/job-applications/types';
import { getJobApplicationStatusMeta } from '@/features/job-applications/components/job-application-status';
import type {
JobApplicationStatus,
JobApplicationStatusLog,
} from '@/features/job-applications/types';
import { formatDate } from '@/features/job-applications/utils';
import { cn } from '@/lib/utils';

type JobApplicationShowTimelineProps = {
statusLogs: JobApplicationStatusLog[];
processDurationLabel: string | null;
topAction?: ReactNode;
};

function TimelineIcon({ icon }: { icon: IconSvgElement }) {
return <HugeiconsIcon icon={icon} className="size-4" />;
}

function getTimelineIconTone(status: JobApplicationStatus): string {
if (status === 'rejected') {
return 'border-destructive/45 bg-destructive/20 text-destructive';
}

if (status === 'accepted') {
return 'border-emerald-500/45 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400';
}

if (status === 'offering') {
return 'border-sky-500/45 bg-sky-500/20 text-sky-600 dark:text-sky-400';
}

if (status === 'interview') {
return 'border-primary/45 bg-primary/20 text-primary';
}

return 'border-border/60 bg-muted/70 text-muted-foreground';
}

function getTimelineStatusTextTone(status: JobApplicationStatus): string {
if (status === 'rejected') {
return 'text-destructive';
}

if (status === 'accepted') {
return 'text-emerald-600 dark:text-emerald-400';
}

if (status === 'offering') {
return 'text-sky-600 dark:text-sky-400';
}

if (status === 'interview') {
return 'text-primary';
}

return 'text-foreground';
}

export function JobApplicationShowTimeline({
statusLogs,
processDurationLabel,
topAction,
}: JobApplicationShowTimelineProps) {
return (
<Card>
Expand All @@ -47,6 +90,20 @@ export function JobApplicationShowTimeline({

<CardContent>
<ol className="space-y-8">
{topAction ? (
<li className="relative pl-12">
<span className="absolute top-0 left-0 z-10 flex size-9 items-center justify-center rounded-full border border-dashed border-border/70 bg-background text-muted-foreground">
+
</span>

<div className="rounded-xl border border-dashed border-border/70 bg-muted/20 p-3">
{topAction}
</div>

<span className="absolute top-10 left-4.5 h-[calc(100%+1.2rem)] w-px bg-border/70" />
</li>
) : null}

{statusLogs.map((log, index) => {
const statusMeta = getJobApplicationStatusMeta(
log.to_status,
Expand All @@ -55,23 +112,36 @@ export function JobApplicationShowTimeline({
return (
<li key={log.id} className="relative pl-12">
{index < statusLogs.length - 1 ? (
<span className="absolute top-10 left-[1.18rem] h-[calc(100%+1.2rem)] w-px bg-border/70" />
<span className="absolute top-0 left-4.5 h-[calc(100%+2rem)] w-px bg-border/70" />
) : null}

<span
className={cn(
'absolute top-0 left-0 flex size-9 items-center justify-center rounded-full border border-border/70 bg-background text-muted-foreground shadow-sm',
'absolute top-0 left-0 z-10 flex size-9 items-center justify-center rounded-full border border-border/70 bg-background shadow-sm',
)}
>
<TimelineIcon icon={statusMeta.icon} />
<span
className={cn(
'flex size-7 items-center justify-center rounded-full border',
getTimelineIconTone(log.to_status),
)}
>
<TimelineIcon icon={statusMeta.icon} />
</span>
</span>

<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<JobApplicationStatusBadge
status={log.to_status}
label={log.to_label}
/>
<p
className={cn(
'text-sm font-semibold tracking-tight',
getTimelineStatusTextTone(
log.to_status,
),
)}
>
{log.to_label}
</p>
<span className="text-xs text-muted-foreground">
{formatDate(
log.changed_at ??
Expand Down
23 changes: 23 additions & 0 deletions resources/js/pages/job-applications/show.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Pencil } from '@hugeicons/core-free-icons';
import { HugeiconsIcon } from '@hugeicons/react';
import { Head } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { JobApplicationShowHero } from '@/features/job-applications/components/job-application-show-hero';
import { JobApplicationShowNotesPanel } from '@/features/job-applications/components/job-application-show-notes-panel';
import { JobApplicationShowTimeline } from '@/features/job-applications/components/job-application-show-timeline';
import { JobApplicationUpdateDialog } from '@/features/job-applications/components/job-application-update-dialog';
import type {
JobApplicationDetail,
JobApplicationStatusLabels,
Expand Down Expand Up @@ -73,6 +77,25 @@ export default function JobApplicationsShow({
<JobApplicationShowTimeline
statusLogs={statusLogs}
processDurationLabel={processDuration}
topAction={
<JobApplicationUpdateDialog
jobApplication={jobApplication}
statuses={statuses}
trigger={
<Button
size="sm"
variant="ghost"
className="w-full justify-center py-2 md:py-4"
>
<HugeiconsIcon
icon={Pencil}
data-icon="inline-start"
/>
Update progress
</Button>
}
/>
}
/>
</div>
</div>
Expand Down
Loading