Skip to content
Merged
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
7 changes: 7 additions & 0 deletions resources/js/components/commit-composer-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { useSharedState } from '@/hooks/use-shared-state';
import type { IssueKind } from '@/lib/issue-commands';

const bodyId = 'commit-dialog-body';

Expand Down Expand Up @@ -43,12 +44,17 @@ export function useCommitDialogClosedCount(): number {
return closedCount;
}

/**
* Opens as a feature request or bug report when given an issue, as if `/feature` or `/bug` had been typed.
*/
export function CommitComposerDialog({
open,
onOpenChange,
issue = null,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
issue?: IssueKind | null;
}) {
const [, setClosedCount] = useSharedState<number>(closedCountKey, 0);

Expand Down Expand Up @@ -103,6 +109,7 @@ export function CommitComposerDialog({
<CommitComposer
id={bodyId}
isInDialog
issue={issue}
onCommitted={close}
/>
</DialogContent>
Expand Down
4 changes: 4 additions & 0 deletions resources/js/components/commit-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
maxRawBodyLength,
minBodyLength,
} from '@/lib/commit';
import type { IssueKind } from '@/lib/issue-commands';
import { hasModKey, useKeyLabels } from '@/lib/keyboard';
import {
characterCount,
Expand Down Expand Up @@ -559,10 +560,12 @@ function CommitComposerBody({
export function CommitComposer({
id = 'body',
isInDialog = false,
issue: initialIssue = null,
onCommitted,
}: {
id?: string;
isInDialog?: boolean;
issue?: IssueKind | null;
onCommitted?: () => void;
}) {
const schedule = useCommitSchedule();
Expand Down Expand Up @@ -597,6 +600,7 @@ export function CommitComposer({
!hasImages &&
poll === null &&
!isScheduling,
initialKind: initialIssue,
onCreated: () => {
drop();
focusBody(id);
Expand Down
143 changes: 143 additions & 0 deletions resources/js/components/go-live-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Form, router } from '@inertiajs/react';
import { Radio } from 'lucide-react';
import { useState } from 'react';
import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';

/**
* Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live.
*/
const maxHours = 12;

const defaultHours = 2;

export function endStream(callbacks?: {
onStart?: () => void;
onFinish?: () => void;
}): void {
router.visit(UserLiveController.destroy(), {
preserveScroll: true,
...callbacks,
onSuccess: () => router.flushAll(),
});
}

export function GoLiveDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [hours, setHours] = useState(defaultHours);
const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`;
const filled = ((hours - 1) / (maxHours - 1)) * 100;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogTitle>Go live</DialogTitle>
<DialogDescription>
Share your YouTube or Twitch stream and your avatar gets a
live marker that sends people to it, for as long as you
choose.
</DialogDescription>

<Form
{...UserLiveController.update.form()}
options={{ preserveScroll: true }}
onSuccess={() => {
onOpenChange(false);
setHours(defaultHours);
router.flushAll();
}}
resetOnSuccess
>
{({ processing, errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="live-url">Stream link</Label>
<input
id="live-url"
type="url"
name="url"
required
autoFocus
placeholder="https://www.youtube.com/live/… or https://twitch.tv/…"
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
data-test="live-url-input"
/>
<InputError message={errors.url} />
</div>

<div className="grid gap-3">
<div className="flex items-baseline justify-between gap-4">
<Label htmlFor="live-hours">For</Label>
<output
htmlFor="live-hours"
className="min-w-[8ch] text-right font-mono text-[13px] text-muted-foreground tabular-nums"
>
{hoursLabel}
</output>
</div>
<input
id="live-hours"
type="range"
name="hours"
min={1}
max={maxHours}
value={hours}
onChange={(event) =>
setHours(
event.currentTarget.valueAsNumber,
)
}
aria-valuetext={hoursLabel}
style={{
background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`,
}}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none"
data-test="live-hours-slider"
/>
<div
aria-hidden
className="flex justify-between font-mono text-xs text-muted-foreground"
>
<span>1h</span>
<span>{maxHours}h</span>
</div>
<InputError message={errors.hours} />
</div>

<DialogFooter className="mt-2 gap-2">
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={processing}
data-test="go-live-button"
>
<Radio />
Go live
</Button>
</DialogFooter>
</div>
)}
</Form>
</DialogContent>
</Dialog>
);
}
6 changes: 5 additions & 1 deletion resources/js/components/issue-command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,18 @@ export function useIssueCommand({
body,
updateBody,
enabled,
initialKind = null,
onCreated,
}: {
body: string;
updateBody: (body: string) => void;
enabled: boolean;
initialKind?: IssueKind | null;
onCreated: () => void;
}) {
const [kind, setKind] = useState<IssueKind | null>(null);
const [kind, setKind] = useState<IssueKind | null>(
enabled ? initialKind : null,
);
const [selected, setSelected] = useState(0);
const [dismissed, setDismissed] = useState(false);
const [focused, setFocused] = useState(false);
Expand Down
136 changes: 8 additions & 128 deletions resources/js/components/profile-actions-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,22 @@
import { Form, router } from '@inertiajs/react';
import { Ellipsis, Radio, RadioOff } from 'lucide-react';
import { useState } from 'react';
import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController';
import InputError from '@/components/input-error';
import { endStream, GoLiveDialog } from '@/components/go-live-dialog';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Label } from '@/components/ui/label';
import type { User } from '@/types';

/**
* Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live.
*/
const maxHours = 12;

const defaultHours = 2;

/**
* The quick actions on your own profile, where other people's profiles have the mute and block menu.
*/
export function ProfileActionsMenu({ user }: { user: Pick<User, 'live'> }) {
const [isGoingLive, setIsGoingLive] = useState(false);
const [isEnding, setIsEnding] = useState(false);
const [hours, setHours] = useState(defaultHours);
const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`;
const filled = ((hours - 1) / (maxHours - 1)) * 100;

const endStream = () =>
router.visit(UserLiveController.destroy(), {
preserveScroll: true,
onStart: () => setIsEnding(true),
onFinish: () => setIsEnding(false),
onSuccess: () => router.flushAll(),
});

return (
<>
Expand All @@ -64,7 +36,12 @@ export function ProfileActionsMenu({ user }: { user: Pick<User, 'live'> }) {
{user.live ? (
<DropdownMenuItem
disabled={isEnding}
onSelect={endStream}
onSelect={() =>
endStream({
onStart: () => setIsEnding(true),
onFinish: () => setIsEnding(false),
})
}
data-test="end-stream"
>
<RadioOff />
Expand All @@ -82,104 +59,7 @@ export function ProfileActionsMenu({ user }: { user: Pick<User, 'live'> }) {
</DropdownMenuContent>
</DropdownMenu>

<Dialog open={isGoingLive} onOpenChange={setIsGoingLive}>
<DialogContent>
<DialogTitle>Go live</DialogTitle>
<DialogDescription>
Share your YouTube or Twitch stream and your avatar gets
a live marker that sends people to it, for as long as
you choose.
</DialogDescription>

<Form
{...UserLiveController.update.form()}
options={{ preserveScroll: true }}
onSuccess={() => {
setIsGoingLive(false);
setHours(defaultHours);
router.flushAll();
}}
resetOnSuccess
>
{({ processing, errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="live-url">
Stream link
</Label>
<input
id="live-url"
type="url"
name="url"
required
autoFocus
placeholder="https://www.youtube.com/live/… or https://twitch.tv/…"
className="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
data-test="live-url-input"
/>
<InputError message={errors.url} />
</div>

<div className="grid gap-3">
<div className="flex items-baseline justify-between gap-4">
<Label htmlFor="live-hours">For</Label>
<output
htmlFor="live-hours"
className="min-w-[8ch] text-right font-mono text-[13px] text-muted-foreground tabular-nums"
>
{hoursLabel}
</output>
</div>
<input
id="live-hours"
type="range"
name="hours"
min={1}
max={maxHours}
value={hours}
onChange={(event) =>
setHours(
event.currentTarget
.valueAsNumber,
)
}
aria-valuetext={hoursLabel}
style={{
background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`,
}}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none"
data-test="live-hours-slider"
/>
<div
aria-hidden
className="flex justify-between font-mono text-xs text-muted-foreground"
>
<span>1h</span>
<span>{maxHours}h</span>
</div>
<InputError message={errors.hours} />
</div>

<DialogFooter className="mt-2 gap-2">
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={processing}
data-test="go-live-button"
>
<Radio />
Go live
</Button>
</DialogFooter>
</div>
)}
</Form>
</DialogContent>
</Dialog>
<GoLiveDialog open={isGoingLive} onOpenChange={setIsGoingLive} />
</>
);
}
Loading