2025-11-30 22:44:47 +05:00
|
|
|
<!--
|
|
|
|
|
@component
|
|
|
|
|
|
|
|
|
|
Metro-like tile. Must be in a group to display correctly.
|
|
|
|
|
-->
|
|
|
|
|
|
|
|
|
|
<script lang="ts">
|
2025-12-01 10:36:25 +05:00
|
|
|
import randomNumber from "$lib/utils/random-number";
|
2025-11-30 22:44:47 +05:00
|
|
|
import { cva } from "class-variance-authority";
|
2025-12-01 10:36:25 +05:00
|
|
|
import { onDestroy, onMount } from "svelte";
|
2025-12-01 19:13:42 +05:00
|
|
|
import TileIconPage from "./pages/TileIconPage.svelte";
|
2025-11-30 22:44:47 +05:00
|
|
|
|
2025-12-01 15:15:55 +05:00
|
|
|
/**
|
|
|
|
|
* Max angle value.
|
|
|
|
|
*/
|
|
|
|
|
const MAX_ANGLE = 10;
|
|
|
|
|
|
2025-12-01 09:27:37 +05:00
|
|
|
/**
|
2025-12-01 10:36:25 +05:00
|
|
|
* Base size of a icon in pixels.
|
2025-12-01 09:27:37 +05:00
|
|
|
**/
|
2025-11-30 23:22:45 +05:00
|
|
|
const ICON_BASE_SIZE = 45;
|
|
|
|
|
|
2025-12-01 10:36:25 +05:00
|
|
|
/**
|
|
|
|
|
* Icon sizes.
|
|
|
|
|
*/
|
|
|
|
|
const ICON_SIZES = {
|
|
|
|
|
small: ICON_BASE_SIZE,
|
|
|
|
|
medium: ICON_BASE_SIZE * 2,
|
|
|
|
|
wide: ICON_BASE_SIZE * 2,
|
|
|
|
|
large: ICON_BASE_SIZE * 4,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Base size of a page in pixels.
|
|
|
|
|
*/
|
2025-12-01 15:15:55 +05:00
|
|
|
const TILE_BASE_SIZE = 75;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Page heights.
|
|
|
|
|
*/
|
|
|
|
|
const PAGE_HEIGHTS = {
|
2025-12-01 15:15:55 +05:00
|
|
|
small: TILE_BASE_SIZE,
|
|
|
|
|
medium: TILE_BASE_SIZE * 2,
|
|
|
|
|
wide: TILE_BASE_SIZE * 2,
|
|
|
|
|
large: TILE_BASE_SIZE * 4,
|
2025-12-01 10:36:25 +05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Max scroll interval delay in milliseconds.
|
|
|
|
|
*/
|
2025-12-01 15:15:55 +05:00
|
|
|
const SCROLL_INTERVAL_MAX = 7.5 * 1000;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Min scroll interval delay in milliseconds.
|
|
|
|
|
*/
|
|
|
|
|
const SCROLL_INTERVAL_MIN = SCROLL_INTERVAL_MAX / 2;
|
|
|
|
|
|
|
|
|
|
/**
|
2025-12-01 15:15:55 +05:00
|
|
|
* Tile pages.
|
2025-12-01 10:36:25 +05:00
|
|
|
*/
|
2025-12-01 15:15:55 +05:00
|
|
|
let pages: HTMLElement;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
2025-12-01 09:27:37 +05:00
|
|
|
/**
|
|
|
|
|
* Properties.
|
|
|
|
|
*/
|
2025-11-30 22:44:47 +05:00
|
|
|
let {
|
|
|
|
|
size,
|
|
|
|
|
row,
|
2025-12-01 11:04:21 +05:00
|
|
|
icon,
|
2025-11-30 22:44:47 +05:00
|
|
|
column,
|
2025-11-30 23:22:45 +05:00
|
|
|
children,
|
2025-12-01 19:13:42 +05:00
|
|
|
link = "#",
|
|
|
|
|
enabled = true,
|
2025-11-30 22:44:47 +05:00
|
|
|
}: {
|
|
|
|
|
/**
|
|
|
|
|
* Size.
|
|
|
|
|
*/
|
|
|
|
|
size: "small" | "medium" | "wide" | "large";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Row index. Must be in a small tiles.
|
|
|
|
|
*/
|
|
|
|
|
row: number;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Column index. Must be in a small tiles.
|
|
|
|
|
*/
|
|
|
|
|
column: number;
|
|
|
|
|
|
2025-12-01 11:04:21 +05:00
|
|
|
/**
|
|
|
|
|
* Icon. Must be a valid URL.
|
|
|
|
|
*/
|
|
|
|
|
icon: string;
|
|
|
|
|
|
2025-12-01 19:13:42 +05:00
|
|
|
/**
|
|
|
|
|
* Link.
|
|
|
|
|
*/
|
|
|
|
|
link?: string;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Is tile enabled?
|
|
|
|
|
*/
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
|
2025-11-30 22:44:47 +05:00
|
|
|
/**
|
2025-11-30 23:22:45 +05:00
|
|
|
* Pages.
|
2025-11-30 22:44:47 +05:00
|
|
|
*/
|
2025-11-30 23:22:45 +05:00
|
|
|
children: () => any;
|
2025-11-30 22:44:47 +05:00
|
|
|
} = $props();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tile style.
|
|
|
|
|
**/
|
2025-12-01 19:13:42 +05:00
|
|
|
const style = cva(["relative", "bg-(--tile-color)/80"], {
|
|
|
|
|
variants: {
|
|
|
|
|
size: {
|
|
|
|
|
small: ["row-span-1", "col-span-1"],
|
|
|
|
|
medium: ["row-span-2", "col-span-2"],
|
|
|
|
|
wide: ["row-span-2", "col-span-4"],
|
|
|
|
|
large: ["row-span-4", "col-span-4"],
|
|
|
|
|
},
|
|
|
|
|
enabled: {
|
|
|
|
|
false: ["grayscale", "opacity-50"],
|
|
|
|
|
true: [
|
|
|
|
|
"active:scale-95",
|
|
|
|
|
"perspective-distant",
|
|
|
|
|
"active:transform-gpu",
|
|
|
|
|
"active:transform-3d",
|
|
|
|
|
"active:rotate-x-(--tile-rotate-x)",
|
|
|
|
|
"active:rotate-y-(--tile-rotate-y)",
|
|
|
|
|
"duration-75",
|
|
|
|
|
"ease-in-out",
|
|
|
|
|
],
|
2025-11-30 22:44:47 +05:00
|
|
|
},
|
|
|
|
|
},
|
2025-12-01 19:13:42 +05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Is tile active?
|
|
|
|
|
*/
|
|
|
|
|
const active = link !== "#" && enabled && children !== null;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Icon size.
|
|
|
|
|
*/
|
|
|
|
|
const iconSize = ICON_SIZES[size];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Page height.
|
|
|
|
|
*/
|
|
|
|
|
const pageHeight = PAGE_HEIGHTS[size];
|
2025-11-30 23:22:45 +05:00
|
|
|
|
2025-12-01 15:15:55 +05:00
|
|
|
/**
|
|
|
|
|
* Mouse position. Used by border (on mouse over).
|
|
|
|
|
*/
|
|
|
|
|
let mousePosition = $state({ x: 0, y: 0 });
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rotation values. Used by transform (on click).
|
|
|
|
|
*/
|
|
|
|
|
let rotationValues = $state({ x: 0, y: 0 });
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Updates the mouse position-based values.
|
|
|
|
|
* @param event `mouseover` event.
|
|
|
|
|
*/
|
|
|
|
|
function updateDynamicMouseValues(event: MouseEvent) {
|
|
|
|
|
const element = event.target as HTMLElement;
|
|
|
|
|
const bounds = element.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
mousePosition = {
|
|
|
|
|
x: event.clientX - bounds.left,
|
|
|
|
|
y: event.clientY - bounds.top,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const tileCenterX = bounds.width / 2;
|
|
|
|
|
const tileCenterY = bounds.height / 2;
|
|
|
|
|
|
|
|
|
|
rotationValues.x =
|
|
|
|
|
-((mousePosition.y - tileCenterY) / tileCenterY) * MAX_ANGLE;
|
|
|
|
|
rotationValues.y =
|
|
|
|
|
-((tileCenterX - mousePosition.x) / tileCenterX) * MAX_ANGLE;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 23:22:45 +05:00
|
|
|
/**
|
2025-12-01 14:21:03 +05:00
|
|
|
* Current page number.
|
2025-11-30 23:22:45 +05:00
|
|
|
*/
|
2025-12-01 14:21:03 +05:00
|
|
|
let currentPage = 1;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
2025-12-01 14:21:03 +05:00
|
|
|
* Scroll orientation.
|
2025-12-01 10:36:25 +05:00
|
|
|
*/
|
2025-12-01 14:21:03 +05:00
|
|
|
let scrollOrientation: "up" | "down" = "down";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Page count.
|
|
|
|
|
*/
|
|
|
|
|
let pageCount: number;
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scroll between pages. Used by scroll interval.
|
|
|
|
|
*/
|
|
|
|
|
function scrollPages() {
|
2025-12-01 14:21:03 +05:00
|
|
|
// NOTE: Handle the current page and multipler.
|
|
|
|
|
let scrollMultipler: number;
|
|
|
|
|
if (scrollOrientation === "down") {
|
|
|
|
|
scrollMultipler = 1;
|
|
|
|
|
currentPage++;
|
|
|
|
|
} else {
|
|
|
|
|
scrollMultipler = -1;
|
|
|
|
|
currentPage--;
|
2025-12-01 10:36:25 +05:00
|
|
|
}
|
|
|
|
|
|
2025-12-01 14:21:03 +05:00
|
|
|
// NOTE: Scroll the pages.
|
2025-12-01 15:15:55 +05:00
|
|
|
pages.scrollBy({ top: pageHeight * scrollMultipler });
|
2025-12-01 14:21:03 +05:00
|
|
|
|
|
|
|
|
// NOTE: Change the orientation after scroll.
|
|
|
|
|
if (currentPage === pageCount) {
|
|
|
|
|
scrollOrientation = "up";
|
|
|
|
|
} else if (currentPage === 1) {
|
|
|
|
|
scrollOrientation = "down";
|
|
|
|
|
}
|
2025-11-30 23:22:45 +05:00
|
|
|
}
|
2025-12-01 10:36:25 +05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scroll interval.
|
|
|
|
|
*/
|
|
|
|
|
let scrollInterval: NodeJS.Timeout;
|
|
|
|
|
|
|
|
|
|
onMount(() => {
|
2025-12-01 15:15:55 +05:00
|
|
|
pageCount = Math.floor(pages.scrollHeight / pageHeight);
|
2025-12-01 14:21:03 +05:00
|
|
|
|
|
|
|
|
if (size === "small" || pageCount === 1) {
|
2025-12-01 10:36:25 +05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scrollDelay = randomNumber(
|
|
|
|
|
SCROLL_INTERVAL_MIN,
|
|
|
|
|
SCROLL_INTERVAL_MAX,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
scrollInterval = setInterval(scrollPages, scrollDelay);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
if (!scrollInterval) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearInterval(scrollInterval);
|
|
|
|
|
});
|
2025-11-30 22:44:47 +05:00
|
|
|
</script>
|
|
|
|
|
|
2025-12-01 15:15:55 +05:00
|
|
|
<a
|
2025-12-01 19:13:42 +05:00
|
|
|
class={style({ size, enabled })}
|
2025-11-30 22:44:47 +05:00
|
|
|
style="
|
2025-12-01 11:04:21 +05:00
|
|
|
--tile-icon: url('{icon}');
|
2025-12-01 10:36:25 +05:00
|
|
|
--tile-icon-size: {iconSize}px;
|
|
|
|
|
--tile-page-height: {pageHeight}px;
|
2025-11-30 23:22:45 +05:00
|
|
|
--tile-name-display: {size !== 'small' ? 'inline' : 'none'};
|
2025-12-01 15:15:55 +05:00
|
|
|
--tile-rotate-x: {rotationValues.x}deg;
|
|
|
|
|
--tile-rotate-y: {rotationValues.y}deg;
|
2025-11-30 22:44:47 +05:00
|
|
|
grid-row-start: {row};
|
2025-11-30 23:22:45 +05:00
|
|
|
grid-column-start: {column};
|
2025-11-30 22:44:47 +05:00
|
|
|
"
|
2025-12-01 19:13:42 +05:00
|
|
|
onmousemove={active ? updateDynamicMouseValues : null}
|
2025-12-01 15:15:55 +05:00
|
|
|
href="#h"
|
2025-11-30 23:22:45 +05:00
|
|
|
>
|
2025-12-01 15:15:55 +05:00
|
|
|
<!-- Tile pages -->
|
|
|
|
|
<div
|
|
|
|
|
class="w-full h-full overflow-y-hidden scroll-smooth duration-150"
|
|
|
|
|
bind:this={pages}
|
|
|
|
|
>
|
2025-12-01 19:13:42 +05:00
|
|
|
{#if active}
|
|
|
|
|
{@render children()}
|
|
|
|
|
{:else}
|
|
|
|
|
<TileIconPage />
|
|
|
|
|
{/if}
|
2025-12-01 15:15:55 +05:00
|
|
|
</div>
|
|
|
|
|
|
2025-12-01 19:13:42 +05:00
|
|
|
{#if active}
|
|
|
|
|
<!-- Tile border -->
|
|
|
|
|
<div
|
|
|
|
|
class="
|
|
|
|
|
absolute top-0
|
|
|
|
|
w-full h-full
|
|
|
|
|
border-2 border-white
|
|
|
|
|
opacity-0 hover:opacity-50 hover:mask-(--mask) active:opacity-100
|
|
|
|
|
duration-75 ease-in-out
|
|
|
|
|
"
|
|
|
|
|
style="
|
|
|
|
|
--mask: radial-gradient(
|
|
|
|
|
{TILE_BASE_SIZE}px
|
|
|
|
|
at {mousePosition.x}px {mousePosition.y}px,
|
|
|
|
|
black 45%,
|
|
|
|
|
transparent
|
|
|
|
|
);
|
|
|
|
|
"
|
|
|
|
|
></div>
|
|
|
|
|
{/if}
|
2025-12-01 15:15:55 +05:00
|
|
|
</a>
|