mirror of
https://codeberg.org/Freedium-cfd/web.git
synced 2026-03-11 09:04:37 +00:00
feat(Header, BlogCard): enhance UX with search and lazy loading
This commit is contained in:
parent
74f12e2c1d
commit
9fb1c77586
27 changed files with 721 additions and 97 deletions
Binary file not shown.
|
|
@ -17,6 +17,7 @@
|
|||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-sv": "^0.0.18",
|
||||
"lucide-svelte": "^0.453.0",
|
||||
"mode-watcher": "^0.4.1",
|
||||
"postcss": "^8.4.47",
|
||||
|
|
@ -36,6 +37,10 @@
|
|||
"@iconify/svelte": "^4.0.2",
|
||||
"bits-ui": "^0.21.16",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"svelte-bricks": "^0.2.1"
|
||||
"es-toolkit": "^1.25.2",
|
||||
"medium-zoom": "^1.1.0",
|
||||
"svelte-bricks": "^0.2.1",
|
||||
"svelte-lazy": "^1.2.9",
|
||||
"svelte-markdown": "^0.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--background: 0, 0%, 97%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--background: 240 10% 12%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
|
|
@ -76,3 +76,8 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.medium-zoom-overlay,
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 999;
|
||||
}
|
||||
|
|
|
|||
23
new-web/src/lib/components/ui/command/command-dialog.svelte
Normal file
23
new-web/src/lib/components/ui/command/command-dialog.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { Command as CommandPrimitive } from 'cmdk-sv';
|
||||
import Command from './command.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
|
||||
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
|
||||
|
||||
export let open: $$Props['open'] = false;
|
||||
export let value: $$Props['value'] = undefined;
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...$$restProps}>
|
||||
<Dialog.Content class="overflow-hidden p-0 shadow-lg">
|
||||
<Command
|
||||
class="[&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
|
||||
{...$$restProps}
|
||||
bind:value
|
||||
>
|
||||
<slot />
|
||||
</Command>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
12
new-web/src/lib/components/ui/command/command-empty.svelte
Normal file
12
new-web/src/lib/components/ui/command/command-empty.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.EmptyProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CommandPrimitive.Empty>
|
||||
18
new-web/src/lib/components/ui/command/command-group.svelte
Normal file
18
new-web/src/lib/components/ui/command/command-group.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
type $$Props = CommandPrimitive.GroupProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
class={cn(
|
||||
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.Group>
|
||||
23
new-web/src/lib/components/ui/command/command-input.svelte
Normal file
23
new-web/src/lib/components/ui/command/command-input.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import Search from "lucide-svelte/icons/search";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.InputProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
export let value: string = "";
|
||||
</script>
|
||||
|
||||
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
|
||||
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
"placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
bind:value
|
||||
/>
|
||||
</div>
|
||||
24
new-web/src/lib/components/ui/command/command-item.svelte
Normal file
24
new-web/src/lib/components/ui/command/command-item.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ItemProps;
|
||||
|
||||
export let asChild = false;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
{asChild}
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:action
|
||||
let:attrs
|
||||
>
|
||||
<slot {action} {attrs} />
|
||||
</CommandPrimitive.Item>
|
||||
15
new-web/src/lib/components/ui/command/command-list.svelte
Normal file
15
new-web/src/lib/components/ui/command/command-list.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.ListProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.List>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.SeparatorProps;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
22
new-web/src/lib/components/ui/command/command.svelte
Normal file
22
new-web/src/lib/components/ui/command/command.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = CommandPrimitive.CommandProps;
|
||||
|
||||
export let value: $$Props["value"] = undefined;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CommandPrimitive.Root>
|
||||
37
new-web/src/lib/components/ui/command/index.ts
Normal file
37
new-web/src/lib/components/ui/command/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Command as CommandPrimitive } from "cmdk-sv";
|
||||
|
||||
import Root from "./command.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
|
||||
const Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
|
|
@ -26,14 +26,14 @@
|
|||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<div class="relative">
|
||||
<slot />
|
||||
<slot />
|
||||
<!-- <div class="relative">
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-1 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div> -->
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
||||
|
|
|
|||
7
new-web/src/lib/components/ui/skeleton/index.ts
Normal file
7
new-web/src/lib/components/ui/skeleton/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
11
new-web/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
11
new-web/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn("bg-muted animate-pulse rounded-md", className)} {...$$restProps}></div>
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import Lazy from 'svelte-lazy';
|
||||
|
||||
const sizes = ['small', 'medium', 'large'];
|
||||
|
||||
export let id: number;
|
||||
|
|
@ -17,13 +19,15 @@
|
|||
const sizeClasses = {
|
||||
small: 'w-full',
|
||||
medium: 'w-full',
|
||||
large: 'w-full'
|
||||
large: 'w-full',
|
||||
null: 'w-full'
|
||||
};
|
||||
|
||||
const imageClasses = {
|
||||
small: 'h-32',
|
||||
medium: 'h-48',
|
||||
large: 'h-64'
|
||||
large: 'h-64',
|
||||
null: 'h-32'
|
||||
};
|
||||
|
||||
function randomSize(): 'small' | 'medium' | 'large' {
|
||||
|
|
@ -36,35 +40,44 @@
|
|||
class={`border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 rounded-lg shadow-md overflow-hidden ${sizeClasses[size]} transition-transform duration-300 ease-in-out hover:scale-105 mb-4`}
|
||||
>
|
||||
{#if imageUrl}
|
||||
<img src={imageUrl} alt={title} class={`w-full object-cover ${imageClasses[size]}`} />
|
||||
<Lazy height={300}>
|
||||
<img src={imageUrl} alt={title} class={`w-full object-cover ${imageClasses[size]}`} />
|
||||
</Lazy>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold text-zinc-900 dark:text-zinc-100">{title}</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">{excerpt}</p>
|
||||
|
||||
<div class="flex flex-wrap items-center mt-2 space-x-2 text-sm text-gray-500 dark:text-white">
|
||||
<Icon icon="mage:medium" class="w-4 h-4 mr-1" />
|
||||
{#if collection}
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="https://miro.medium.com/v2/resize:fill:48:48/{collection.avatarId}"
|
||||
alt={collection.name.charAt(0)}
|
||||
loading="eager"
|
||||
class="w-4 h-4 mr-1 rounded-full no-lightense"
|
||||
/>
|
||||
<p>{collection.name}</p>
|
||||
</div>
|
||||
<span>·</span>
|
||||
{/if}
|
||||
<span>{creator}</span>
|
||||
<span>·</span>
|
||||
<span>~{readingTime} min read</span>
|
||||
<span class="md:inline">·</span>
|
||||
<span>{publishedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if bottomImageUrl}
|
||||
<img src={bottomImageUrl} alt={title} class={`w-full object-cover ${imageClasses[size]}`} />
|
||||
<Lazy height={300}>
|
||||
<img
|
||||
src={bottomImageUrl}
|
||||
alt={title}
|
||||
class={`lazy w-full object-cover ${imageClasses[size]}`}
|
||||
/>
|
||||
</Lazy>
|
||||
{/if}
|
||||
<div
|
||||
class="flex flex-wrap items-center p-4 mt-2 space-x-2 text-sm text-gray-500 dark:text-white"
|
||||
>
|
||||
<Icon icon="mage:medium" class="w-4 h-4 mr-1" />
|
||||
{#if collection}
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="https://miro.medium.com/v2/resize:fill:48:48/{collection.avatarId}"
|
||||
alt={collection.name.charAt(0)}
|
||||
loading="eager"
|
||||
class="w-4 h-4 mr-1 rounded-full no-lightense"
|
||||
/>
|
||||
<p>{collection.name}</p>
|
||||
</div>
|
||||
<span>·</span>
|
||||
{/if}
|
||||
<span>{creator}</span>
|
||||
<span>·</span>
|
||||
<span>~{readingTime} min read</span>
|
||||
<span class="md:inline">·</span>
|
||||
<span>{publishedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
56
new-web/src/lib/elements/Footer.svelte
Normal file
56
new-web/src/lib/elements/Footer.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class="z-20 w-full bg-white border-t shadow-sm dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="container flex flex-col items-center justify-between px-4 py-6 mx-auto space-y-4 md:flex-row md:space-y-0"
|
||||
>
|
||||
<div class="text-sm text-center text-zinc-500 dark:text-zinc-400">
|
||||
By Freedium and its contributors.<br />
|
||||
Made with ❤️
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-wrap justify-center space-x-4">
|
||||
<a
|
||||
href="/about"
|
||||
class="text-sm text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
href="/privacy"
|
||||
class="text-sm text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
>Privacy</a
|
||||
>
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-sm text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
>Terms</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href="https://github.com/Freedium-cfd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon class="size-5" icon="simple-icons:github" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href="https://codeberg.org/Freedium-cfd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon class="size-5" icon="simple-icons:codeberg" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -5,14 +5,22 @@
|
|||
import ReportProblem from './ReportProblem.svelte';
|
||||
import PayButtons from './PayButtons.svelte';
|
||||
import ExtensionsButton from './ExtensionsButton.svelte';
|
||||
|
||||
import Icon from '@iconify/svelte';
|
||||
import SearchDialog from './SearchDialog.svelte';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
|
||||
let isNavOpen = false;
|
||||
|
||||
const toggleNav = () => {
|
||||
isNavOpen = !isNavOpen;
|
||||
};
|
||||
|
||||
let isSearchOpen = false;
|
||||
const toggleSearch = () => {
|
||||
isSearchOpen = !isSearchOpen;
|
||||
};
|
||||
</script>
|
||||
|
||||
<nav
|
||||
|
|
@ -22,22 +30,25 @@
|
|||
<Advertise />
|
||||
<ProgressLine />
|
||||
|
||||
<div class="container flex items-center justify-between h-16 px-4 mx-auto">
|
||||
<div class="container flex items-center justify-between h-16 px-4 mx-auto space-x-2">
|
||||
<a class="text-2xl font-bold transition-opacity text-primary hover:opacity-80" href="/"
|
||||
>Freedium</a
|
||||
>Freedium βeta</a
|
||||
>
|
||||
|
||||
<button
|
||||
id="nav-toggle"
|
||||
on:click={toggleNav}
|
||||
class="p-2 transition-colors rounded-full md:hidden hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<Menu class="w-6 h-6" />
|
||||
</button>
|
||||
<div class="flex-grow max-w-md mx-2">
|
||||
<Button
|
||||
class="items-center hidden w-full h-8 gap-2 pl-2 pr-3 text-sm transition bg-white rounded-full ui-not-focus-visible:outline-none text-zinc-500 ring-1 ring-zinc-900/10 hover:ring-zinc-900/20 lg:flex dark:bg-white/5 dark:text-zinc-400 dark:ring-inset dark:ring-white/10 dark:hover:ring-white/20"
|
||||
on:click={toggleSearch}
|
||||
>
|
||||
<Icon icon="heroicons:magnifying-glass" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="items-center hidden space-x-2 md:flex">
|
||||
<ThemeToggle />
|
||||
<Button class="lg:hidden" variant="ghost" size="icon" on:click={toggleSearch}>
|
||||
<Icon class="size-5" icon="heroicons:magnifying-glass" />
|
||||
</Button>
|
||||
<ExtensionsButton />
|
||||
<div class="w-px h-6 bg-zinc-300 dark:bg-zinc-700"></div>
|
||||
<PayButtons name="Ko-fi" url="https://ko-fi.com/zhymabekroman" icon="teenyicons:cup-solid" />
|
||||
|
|
@ -46,9 +57,34 @@
|
|||
url="https://liberapay.com/ZhymabekRoman/"
|
||||
icon="simple-icons:liberapay"
|
||||
/>
|
||||
<PayButtons name="PayPal" url="" icon="simple-icons:paypal" />
|
||||
<!-- <PayButtons name="PayPal" url="" icon="simple-icons:paypal" /> -->
|
||||
<div class="w-px h-6 bg-zinc-300 dark:bg-zinc-700"></div>
|
||||
<ThemeToggle />
|
||||
<ReportProblem />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 md:hidden">
|
||||
<Button class="lg:hidden" variant="ghost" size="icon" on:click={toggleSearch}>
|
||||
<Icon class="size-5" icon="heroicons:magnifying-glass" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon" on:click={toggleNav}>
|
||||
<Menu />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isNavOpen}
|
||||
<div
|
||||
class="!sticky w-full bg-white border-b shadow-sm md:hidden dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="sticky z-20 flex flex-wrap items-center justify-center px-4 py-2 mx-auto space-x-2 space-y-2"
|
||||
>
|
||||
<ReportProblem />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<SearchDialog bind:open={isSearchOpen} />
|
||||
|
|
|
|||
33
new-web/src/lib/elements/ImageZoom.svelte
Normal file
33
new-web/src/lib/elements/ImageZoom.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import mediumZoom from 'medium-zoom';
|
||||
|
||||
export let src = undefined;
|
||||
export let alt = undefined;
|
||||
export let options = undefined;
|
||||
|
||||
let zoom = null;
|
||||
|
||||
function getZoom() {
|
||||
if (zoom === null) {
|
||||
zoom = mediumZoom(options);
|
||||
}
|
||||
|
||||
return zoom;
|
||||
}
|
||||
|
||||
function attachZoom(image) {
|
||||
const zoom = getZoom();
|
||||
zoom.attach(image);
|
||||
|
||||
return {
|
||||
update(newOptions) {
|
||||
zoom.update(newOptions);
|
||||
},
|
||||
destroy() {
|
||||
zoom.detach();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<img {src} {alt} {...$$restProps} use:attachZoom={options} />
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
|
||||
export let columnWidth = 300; // Default column width
|
||||
export let gap = 16; // Gap between items
|
||||
|
||||
let container: HTMLElement;
|
||||
let items: HTMLElement[];
|
||||
let columns: number = 1;
|
||||
|
||||
function updateLayout() {
|
||||
if (!container) return;
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
columns = Math.floor(containerWidth / (columnWidth + gap));
|
||||
const actualColumnWidth = (containerWidth - (columns - 1) * gap) / columns;
|
||||
|
||||
container.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||||
container.style.gridGap = `${gap}px`;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const column = i % columns;
|
||||
const prevItem = items[i - columns];
|
||||
const top = prevItem ? prevItem.getBoundingClientRect().bottom + gap : 0;
|
||||
|
||||
item.style.gridColumn = `${column + 1}`;
|
||||
item.style.gridRow = `${Math.floor(top / 10)}`;
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
items = Array.from(container.children) as HTMLElement[];
|
||||
updateLayout();
|
||||
window.addEventListener('resize', updateLayout);
|
||||
});
|
||||
|
||||
afterUpdate(updateLayout);
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="masonry-grid">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.masonry-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -53,6 +53,10 @@
|
|||
<RadioGroup.Item value="suggestion" id="suggestion" />
|
||||
<Label.Root for="suggestion">Suggestion</Label.Root>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="vulnerability" id="vulnerability" />
|
||||
<Label.Root for="vulnerability">Vulnerability (XSS, etc.)</Label.Root>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="other" id="other" />
|
||||
<Label.Root for="other">Other</Label.Root>
|
||||
|
|
|
|||
126
new-web/src/lib/elements/SearchDialog.svelte
Normal file
126
new-web/src/lib/elements/SearchDialog.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Command from '$lib/components/ui/command/index.js';
|
||||
import { debounce } from 'es-toolkit';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
|
||||
let value = '';
|
||||
let searchQuery = '';
|
||||
export let open = false;
|
||||
let loading = false;
|
||||
|
||||
type Post = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: Date;
|
||||
excerpt: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
let searchResults: Post[] = [];
|
||||
|
||||
const mockSearch = async (query: string): Promise<Post[]> => {
|
||||
loading = true;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500)); // Increased delay for demo
|
||||
|
||||
loading = false;
|
||||
|
||||
return [
|
||||
{
|
||||
id: Math.random().toString(),
|
||||
title: `Post 1 about ${query}`,
|
||||
date: new Date(),
|
||||
excerpt: 'This is a short excerpt of the post...',
|
||||
imageUrl: 'https://picsum.photos/50/50?random=1'
|
||||
},
|
||||
{
|
||||
id: Math.random().toString(),
|
||||
title: `Post 2 mentioning ${query}`,
|
||||
date: new Date(Date.now() - 86400000),
|
||||
excerpt: 'Another interesting excerpt here...',
|
||||
imageUrl: 'https://picsum.photos/50/50?random=2'
|
||||
},
|
||||
{
|
||||
id: Math.random().toString(),
|
||||
title: `Post 3 related to ${query}`,
|
||||
date: new Date(Date.now() - 172800000),
|
||||
excerpt: 'Yet another captivating excerpt...',
|
||||
imageUrl: 'https://picsum.photos/50/50?random=3'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async (query: string) => {
|
||||
const searchResultsData = await mockSearch(query);
|
||||
searchResults = searchResultsData;
|
||||
// if (searchQuery !== '') {
|
||||
// }
|
||||
}, 300);
|
||||
|
||||
$: {
|
||||
if (searchQuery) {
|
||||
console.log('searchQuery', searchQuery);
|
||||
debouncedSearch(searchQuery);
|
||||
} else {
|
||||
searchResults = [];
|
||||
loading = false;
|
||||
debouncedSearch.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
const defaultImageUrl = 'https://via.placeholder.com/50';
|
||||
</script>
|
||||
|
||||
<Command.Dialog bind:open bind:value shouldFilter={false} loop>
|
||||
<Command.Input placeholder="Search posts..." bind:value={searchQuery} />
|
||||
<Command.List>
|
||||
<!-- <Command.Empty>No posts found. Try a different search term.</Command.Empty> -->
|
||||
|
||||
{#if loading}
|
||||
{#each Array(3) as _}
|
||||
<div class="flex items-start py-2 m-2">
|
||||
<Skeleton class="w-12 h-12 mr-3 rounded" />
|
||||
<div class="flex flex-col flex-grow">
|
||||
<Skeleton class="w-3/4 h-5 mb-1" />
|
||||
<Skeleton class="w-1/4 h-4 mb-1" />
|
||||
<Skeleton class="w-full h-4" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each searchResults as post}
|
||||
<Command.Item value={post.id} class="flex items-start py-2">
|
||||
<img
|
||||
src={post.imageUrl || defaultImageUrl}
|
||||
alt={post.title}
|
||||
class="object-cover w-12 h-12 mr-3 rounded"
|
||||
/>
|
||||
<div class="flex flex-col flex-grow">
|
||||
<span class="font-medium">{post.title}</span>
|
||||
<div class="mt-1 text-sm text-gray-500">{formatDate(post.date)}</div>
|
||||
<div class="mt-1 text-sm">{post.excerpt}</div>
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if searchResults.length === 0 && !loading}
|
||||
<Command.Item>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm italic text-muted-foreground"
|
||||
>Start typing post name to search or enter URL.</span
|
||||
>
|
||||
<span class="text-sm italic text-muted-foreground">For example:</span>
|
||||
<span class="text-sm italic text-muted-foreground">
|
||||
- https://medium.com/post-test-1</span
|
||||
>
|
||||
<span class="text-sm italic text-muted-foreground"> - How to create a blog post</span>
|
||||
</div>
|
||||
</Command.Item>
|
||||
{/if}
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
9
new-web/src/lib/utils/dateFormatter.js
Normal file
9
new-web/src/lib/utils/dateFormatter.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export function formatDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import Header from '$lib/elements/Header.svelte';
|
||||
import Masonry from 'svelte-bricks';
|
||||
|
||||
import HomeBanner from '$lib/elements/HomeBanner.svelte';
|
||||
import BlogCard from '$lib/elements/BlogCard.svelte';
|
||||
import Footer from '$lib/elements/Footer.svelte';
|
||||
|
||||
const blogPosts = [
|
||||
{
|
||||
|
|
@ -105,12 +105,9 @@
|
|||
}
|
||||
];
|
||||
|
||||
// Reactive statement to create items array from blogPosts
|
||||
$: items = blogPosts.map((post, index) => ({ ...post, id: index }));
|
||||
|
||||
// Masonry configuration
|
||||
let [minColWidth, maxColWidth, gap] = [300, 600, 20];
|
||||
let width, height;
|
||||
</script>
|
||||
|
||||
<head>
|
||||
|
|
@ -125,3 +122,5 @@
|
|||
<BlogCard {...item} />
|
||||
</Masonry>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
|
|
|||
5
new-web/src/routes/[slug]/+page.js
Normal file
5
new-web/src/routes/[slug]/+page.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const load = ({ params }) => {
|
||||
return {
|
||||
slug: params.slug
|
||||
};
|
||||
};
|
||||
164
new-web/src/routes/[slug]/+page.svelte
Normal file
164
new-web/src/routes/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script>
|
||||
import Header from '$lib/elements/Header.svelte';
|
||||
import SvelteMarkdown from 'svelte-markdown';
|
||||
import { formatDate } from '$lib/utils/dateFormatter';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ImageZoom from '$lib/elements/ImageZoom.svelte';
|
||||
|
||||
let data = {
|
||||
title: 'UploadThing is 5x Faster',
|
||||
date: '2024-09-13T12:00:00Z',
|
||||
author: {
|
||||
name: 'Theo Browne',
|
||||
role: 'CEO @ Ping Labs',
|
||||
avatar: 'https://picsum.photos/seed/post1/400/300'
|
||||
},
|
||||
postImage: 'https://picsum.photos/seed/postimage/1200/600', // This can now be undefined or null
|
||||
content: `
|
||||
## V7 Is Here!
|
||||
|
||||
This release has been an absurd amount of work. So proud of the team and what we've built. Huge thanks to [Julius](#) and [Mark](#) for making this happen.
|
||||
|
||||
It is so, so hard to not go straight into the nerdy details, but the whole point of UploadThing is that you don't need to know ANY of those details. With that in mind, here's what's relevant for most of y'all:
|
||||
|
||||
- UploadThing is now *way* faster
|
||||
- Uploads can be paused and resumed seamlessly (huge for users on bad internet connections)
|
||||
- More details...
|
||||
|
||||
## Revolutionary Features
|
||||
|
||||
We've completely overhauled our backend infrastructure to bring you unparalleled performance. Our new distributed processing system can handle millions of concurrent uploads without breaking a sweat.
|
||||
|
||||
### AI-Powered Optimization
|
||||
|
||||
UploadThing now leverages cutting-edge machine learning algorithms to optimize your uploads in real-time. Whether you're uploading images, videos, or documents, our AI will automatically adjust compression and encoding settings to give you the best possible quality at the smallest file size.
|
||||
|
||||
## Security Enhancements
|
||||
|
||||
We've implemented state-of-the-art encryption protocols to ensure your data remains safe and secure throughout the entire upload process. Our new zero-knowledge architecture means that even we can't access your files without your explicit permission.
|
||||
|
||||
### Compliance and Regulations
|
||||
|
||||
UploadThing is now fully compliant with GDPR, CCPA, and other major data protection regulations worldwide. We've also obtained ISO 27001 certification, demonstrating our commitment to information security management.
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
We're not stopping here. Our team is already hard at work on the next big update. Here's a sneak peek of what's coming:
|
||||
|
||||
- Quantum-resistant encryption for future-proof security
|
||||
- Integration with major cloud storage providers for seamless file management
|
||||
- Advanced analytics dashboard for enterprise users
|
||||
- Support for emerging file formats and codecs
|
||||
|
||||
Stay tuned for more exciting updates as we continue to revolutionize the world of file uploads!
|
||||
`,
|
||||
tableOfContents: [
|
||||
{ id: 'v7-is-here', title: 'V7 Is Here!' },
|
||||
{ id: 'benchmarks', title: 'Benchmarks' },
|
||||
{ id: 'the-road-to-v7', title: 'The Road To V7' },
|
||||
{ id: 'uploadthing-has-served', title: 'UploadThing Has Served...' },
|
||||
{ id: 'and-were-just-getting-started', title: "...and we're just getting started" }
|
||||
]
|
||||
};
|
||||
let contentLoaded = false;
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
contentLoaded = true;
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title} - Freedium</title>
|
||||
<meta name="description" content="Read about the latest updates to UploadThing" />
|
||||
</svelte:head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main class="max-w-5xl px-4 py-8 mx-auto">
|
||||
<nav class="flex items-center justify-between gap-2 mb-4 text-center">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center justify-center transition bg-white rounded-full shadow-md text-primary hover:text-primary/90 group size-8 shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0 dark:ring-white/10 dark:hover:border-zinc-700 dark:hover:ring-white/20"
|
||||
>
|
||||
<Icon icon="heroicons:arrow-left-20-solid" class="size-6" />
|
||||
</a>
|
||||
<a href="/" class="font-bold text-primary hover:text-primary/90"> Original article</a>
|
||||
</nav>
|
||||
|
||||
<div class="lg:flex lg:space-x-8">
|
||||
<article class="flex-grow overflow-hidden bg-white rounded-lg shadow-lg">
|
||||
{#if data.postImage}
|
||||
<ImageZoom
|
||||
src={data.postImage}
|
||||
alt="Post cover image"
|
||||
class="object-cover w-full h-auto max-h-96"
|
||||
/>
|
||||
{/if}
|
||||
<header class="p-6 bg-gray-50">
|
||||
<p class="mb-2 text-gray-600">{formatDate(data.date)}</p>
|
||||
<h1 class="mb-4 text-4xl font-bold text-gray-900">{data.title}</h1>
|
||||
<div class="flex items-center">
|
||||
<img src={data.author.avatar} alt="" class="w-12 h-12 mr-4 rounded-full" />
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">{data.author.name}</p>
|
||||
<p class="text-gray-600">{data.author.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="p-6 {data.postImage ? '' : 'pt-0'}">
|
||||
<div class="prose max-w-none">
|
||||
{#if contentLoaded}
|
||||
<SvelteMarkdown source={data.content} />
|
||||
{:else}
|
||||
<p>Loading content...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="order-first mt-7 lg:mt-0 lg:min-w-80 lg:order-none">
|
||||
<nav
|
||||
aria-labelledby="toc-heading"
|
||||
class="w-full p-4 bg-white rounded-lg shadow-lg lg:sticky lg:top-36"
|
||||
>
|
||||
<h2 id="toc-heading" class="mb-4 text-xl font-semibold text-gray-900">Contents</h2>
|
||||
{#if data.tableOfContents && data.tableOfContents.length > 0}
|
||||
<ul class="space-y-2">
|
||||
{#each data.tableOfContents as item}
|
||||
<li>
|
||||
<a href={`#${item.id}`} class="transition-colors text-zinc-800 hover:text-zinc-900">
|
||||
{item.title}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p>No table of contents available</p>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style lang="postcss">
|
||||
:global(h2) {
|
||||
@apply font-bold font-sans break-normal text-gray-900 dark:text-gray-100 md:text-2xl;
|
||||
}
|
||||
|
||||
:global(*:not(:first-child) + h2) {
|
||||
@apply pt-12;
|
||||
}
|
||||
|
||||
:global(.prose p) {
|
||||
@apply leading-8 mt-7;
|
||||
}
|
||||
|
||||
:global(.prose h3 + p),
|
||||
:global(.prose h4 + p) {
|
||||
@apply mt-3;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue