Add login page with two-column layout

- Two-column layout: dark branding panel (left) and login form (right)
- Responsive design with mobile logo visible on small screens
- Social login buttons (Google, GitHub)
- Loading state with spinner animation
- Uses shadcn-svelte Input and Label components
- Separate layout to bypass dashboard wrapper

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-07 16:00:33 +08:00
parent ed82503a44
commit d260821964
6 changed files with 315 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type {
HTMLInputAttributes,
HTMLInputTypeAttribute,
} from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
(
| { type: "file"; files?: FileList }
| { type?: InputType; files?: undefined }
)
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import type { Snippet } from "svelte";
import "../../app.css";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
let email = $state("");
let password = $state("");
let isLoading = $state(false);
let error = $state("");
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = "";
// Simulate login - replace with actual auth logic
await new Promise((resolve) => setTimeout(resolve, 1000));
// For demo, just redirect to dashboard
goto("/performance");
}
</script>
<svelte:head>
<title>Login | Publisher Dashboard</title>
<meta name="description" content="Sign in to your Publisher Dashboard account" />
</svelte:head>
<div class="grid min-h-screen lg:grid-cols-2">
<!-- Left Panel - Branding -->
<div class="relative hidden bg-primary lg:block">
<div class="absolute inset-0 bg-gradient-to-br from-primary via-primary to-chart-1/20"></div>
<div class="relative flex h-full flex-col justify-between p-10">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-foreground">
<svg
class="h-6 w-6 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-primary-foreground">Publisher Dashboard</span>
</div>
<!-- Testimonial -->
<div class="space-y-4">
<blockquote class="text-lg font-light leading-relaxed text-primary-foreground/90">
"This dashboard has transformed how we analyze our publishing metrics. The insights are
invaluable for optimizing our content strategy and maximizing revenue."
</blockquote>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-foreground/20 text-sm font-medium text-primary-foreground"
>
SK
</div>
<div>
<p class="font-medium text-primary-foreground">Sarah Kim</p>
<p class="text-sm text-primary-foreground/70">Head of Digital, MediaCorp</p>
</div>
</div>
</div>
</div>
</div>
<!-- Right Panel - Login Form -->
<div class="flex items-center justify-center bg-background p-6 lg:p-10">
<div class="mx-auto w-full max-w-sm space-y-8">
<!-- Mobile Logo -->
<div class="flex items-center justify-center gap-3 lg:hidden">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg
class="h-6 w-6 text-primary-foreground"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<span class="text-xl font-semibold text-foreground">Publisher Dashboard</span>
</div>
<!-- Header -->
<div class="space-y-2 text-center lg:text-left">
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Welcome back</h1>
<p class="text-sm text-muted-foreground">
Enter your credentials to access your dashboard
</p>
</div>
<!-- Form -->
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
bind:value={email}
required
class="h-10"
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<a href="/forgot-password" class="text-xs text-muted-foreground hover:text-foreground">
Forgot password?
</a>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
bind:value={password}
required
class="h-10"
/>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Button type="submit" class="h-10 w-full" disabled={isLoading || !email || !password}>
{#if isLoading}
<svg class="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Signing in...
{:else}
Sign in
{/if}
</Button>
</form>
<!-- Divider -->
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-border"></span>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<!-- Social Login -->
<div class="grid grid-cols-2 gap-3">
<Button variant="outline" class="h-10">
<svg class="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google
</Button>
<Button variant="outline" class="h-10">
<svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
GitHub
</Button>
</div>
<!-- Footer -->
<p class="text-center text-xs text-muted-foreground">
By continuing, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-foreground">Terms of Service</a>
and
<a href="/privacy" class="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>
</p>
</div>
</div>
</div>