Skip to content
Closed
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
4 changes: 2 additions & 2 deletions app/Http/Controllers/ModerationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user):
'parent.user',
'linkPreview',
'images',
'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with('user'),
'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'images']),
'seriesParts' => fn (HasMany $query): HasMany => $query
->where('status', CommitStatus::Pending)
->with(['user', 'linkPreview', 'images', 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with('user')])
->with(['user', 'linkPreview', 'images', 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->with(['user', 'images'])])
->oldest('series_position'),
])
->withStarsFor($user)
Expand Down
5 changes: 3 additions & 2 deletions app/Queries/CommitBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,16 @@ public function withVisibleReplyCount(?User $user): self
}

/**
* Loads the quoted commit only when the given user may see it, and as a tombstone once deleted.
* Loads the quoted commit only when the given user may see it, and as a tombstone once deleted. Its images come
* along for the embed's thumbnail; a tombstone never serializes them.
*/
public function withQuotedCommitFor(?User $viewer): self
{
return $this->with([
'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query
->withTrashed()
->where(fn (self $query): self => $query->visibleTo($viewer))
->with('user'),
->with(['user', 'images']),
]);
}

Expand Down
2 changes: 2 additions & 0 deletions resources/js/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { ComponentType } from 'react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ImageViewerHost } from '@/components/image-viewer';
import { Toaster } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { initializeTheme } from '@/hooks/use-appearance';
Expand All @@ -25,6 +26,7 @@ void createInertiaApp({
<TooltipProvider delayDuration={0}>
<App {...props} />
<Toaster />
<ImageViewerHost />
</TooltipProvider>
</StrictMode>,
);
Expand Down
11 changes: 11 additions & 0 deletions resources/js/components/app-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { UserAvatar } from '@/components/user-avatar';
import { UserMenuContent } from '@/components/user-menu-content';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { requestScrollToTop } from '@/lib/scroll-to-top';
import { cn, toUrl } from '@/lib/utils';
import { home, login } from '@/routes';
import { show } from '@/routes/user';
Expand Down Expand Up @@ -134,6 +135,16 @@ function NavLink({
prefetch={item.prefetch ?? true}
viewTransition
aria-current={item.isActive ? 'page' : undefined}
onClick={(event) => {
if (
item.isActive &&
item.title === 'Log' &&
!(event.metaKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
requestScrollToTop();
}
}}
className={className}
>
{children}
Expand Down
11 changes: 9 additions & 2 deletions resources/js/components/bookmark-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ export const BookmarkRemovalContext = createContext<
((id: string) => void) | null
>(null);

export function BookmarkButton({ commit }: { commit: Commit }) {
export function BookmarkButton({
commit,
keyShortcut,
}: {
commit: Commit;
keyShortcut?: string;
}) {
const { auth } = usePage().props;
const [isSaved, setIsSaved] = useState(commit.is_saved ?? false);
const request = useHttp<Record<string, never>, { is_saved: boolean }>({});
Expand Down Expand Up @@ -57,7 +63,8 @@ export function BookmarkButton({ commit }: { commit: Commit }) {
aria-disabled={request.processing}
aria-label={isSaved ? 'Unsave commit' : 'Save commit'}
aria-pressed={isSaved}
title={isSaved ? 'Unsave commit' : 'Save commit privately'}
aria-keyshortcuts={keyShortcut}
title={`${isSaved ? 'Unsave commit' : 'Save commit privately'}${keyShortcut ? ` (${keyShortcut})` : ''}`}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50',
{ 'text-primary hover:text-primary': isSaved },
Expand Down
7 changes: 6 additions & 1 deletion resources/js/components/commit-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export function CommitCard({
<CommitStarButton
key={`${commit.starred}-${commit.stars_count}`}
commit={commit}
keyShortcut="s"
/>
<CommitQuoteButton
commit={commit}
Expand All @@ -344,6 +345,7 @@ export function CommitCard({
<CommitStarButton
key={`${commit.starred}-${commit.stars_count}`}
commit={commit}
keyShortcut="s"
className="px-2 py-0.5"
/>
<CommitQuoteButton
Expand All @@ -361,7 +363,10 @@ export function CommitCard({
{commit.replies_count === undefined &&
commit.is_saved !== undefined && (
<div className="mt-1 -ml-1.5">
<BookmarkButton commit={commit} />
<BookmarkButton
commit={commit}
keyShortcut="b"
/>
</div>
)}

Expand Down
79 changes: 59 additions & 20 deletions resources/js/components/commit-image-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ImagePlus, X } from 'lucide-react';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ImageViewer } from '@/components/image-viewer';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

Expand Down Expand Up @@ -99,6 +100,7 @@ export function ImagePickerPreviews({
images: File[];
onRemove: (index: number) => void;
}) {
const [viewing, setViewing] = useState<number | null>(null);
const urls = useMemo(
() => images.map((image) => URL.createObjectURL(image)),
[images],
Expand All @@ -114,24 +116,61 @@ export function ImagePickerPreviews({
}

return (
<ul className="mt-2 flex gap-2" aria-label="Attached images">
{urls.map((url, index) => (
<li key={url} className="relative">
<img
src={url}
alt={`Attached image ${index + 1}`}
className="size-20 rounded-md border bg-muted object-cover"
/>
<button
type="button"
onClick={() => onRemove(index)}
aria-label={`Remove image ${index + 1}`}
className="absolute top-1 right-1 rounded-full bg-background/80 p-0.5 text-foreground backdrop-blur transition hover:bg-background"
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>
<>
<ul className="mt-2 flex gap-2" aria-label="Attached images">
{urls.map((url, index) => (
<li key={url} className="relative">
<button
type="button"
onClick={() => setViewing(index)}
aria-label={`Preview image ${index + 1}`}
title="Preview"
className="block cursor-zoom-in overflow-hidden rounded-md border bg-muted transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
data-test="attached-image-preview-button"
>
<img
src={url}
alt={`Attached image ${index + 1}`}
className="size-20 object-cover transition-transform duration-200 motion-safe:hover:scale-105"
/>
</button>
<button
type="button"
onClick={() => onRemove(index)}
aria-label={`Remove image ${index + 1}`}
className="absolute top-1 right-1 rounded-full bg-background/80 p-0.5 text-foreground backdrop-blur transition hover:bg-background"
>
<X className="size-3.5" />
</button>
</li>
))}
</ul>

<ImageViewer
images={images.map((image, index) => ({
url: urls[index],
alt: `Attached image ${index + 1}`,
details: `${image.name} · ${formatBytes(image.size)}`,
}))}
index={viewing}
onIndexChange={setViewing}
onRemove={(index) => {
onRemove(index);
setViewing(
images.length <= 1
? null
: Math.min(index, images.length - 2),
);
}}
/>
</>
);
}

function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}

return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
37 changes: 30 additions & 7 deletions resources/js/components/commit-images.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { viewImages } from '@/components/image-viewer';
import { cn } from '@/lib/utils';
import type { CommitImage } from '@/types';

Expand All @@ -22,18 +23,34 @@ export function CommitImages({ images }: { images: CommitImage[] }) {
href={image.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
className={cn('block min-h-0 bg-muted', {
onClick={(event) => {
event.stopPropagation();

if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.button !== 0
) {
return;
}

event.preventDefault();
viewImages(
images.map((image, position) => ({
url: image.url,
alt: altFor(position, images.length),
})),
index,
);
}}
className={cn('block min-h-0 cursor-zoom-in bg-muted', {
'row-span-2': images.length === 3 && index === 0,
})}
>
<img
src={image.url}
alt={
images.length === 1
? 'Attached image'
: `Attached image ${index + 1} of ${images.length}`
}
alt={altFor(index, images.length)}
width={image.width}
height={image.height}
loading="lazy"
Expand All @@ -57,3 +74,9 @@ export function CommitImages({ images }: { images: CommitImage[] }) {
</div>
);
}

function altFor(index: number, count: number): string {
return count === 1
? 'Attached image'
: `Attached image ${index + 1} of ${count}`;
}
5 changes: 4 additions & 1 deletion resources/js/components/commit-star-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ function starLabel(starred: boolean, count: number): string {

export function CommitStarButton({
commit,
keyShortcut,
className,
}: {
commit: Commit;
keyShortcut?: string;
className?: string;
}) {
const { auth } = usePage().props;
Expand Down Expand Up @@ -95,7 +97,8 @@ export function CommitStarButton({
onClick={toggle}
aria-pressed={starred}
aria-label={starLabel(starred, count)}
title={starred ? 'Unstar' : 'Star'}
aria-keyshortcuts={keyShortcut}
title={`${starred ? 'Unstar' : 'Star'}${keyShortcut ? ` (${keyShortcut})` : ''}`}
className={cn(
'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
starred && 'text-primary hover:text-primary',
Expand Down
5 changes: 4 additions & 1 deletion resources/js/components/commit-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export function ThreadActions({

<div className="ml-auto flex items-center gap-0.5">
{!isDeleted(commit) && commit.is_saved !== undefined && (
<BookmarkButton commit={commit} />
<BookmarkButton
commit={commit}
keyShortcut={hasKeyboardShortcuts ? 'b' : undefined}
/>
)}
{!isDeleted(commit) && (
<CopyLinkButton
Expand Down
Loading