Initial commit

This commit is contained in:
Georgi Gardev
2023-11-17 18:24:36 +02:00
commit 8fd3d97cd1
49 changed files with 7091 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export const getAppIcon = (icon: string) =>
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/${icon}.png`;
+31
View File
@@ -0,0 +1,31 @@
import { createSignal } from 'solid-js';
import { WeatherData } from '~/types';
import { Accessor } from 'solid-js';
export function fetchWeather(): Promise<WeatherData> {
const lat = import.meta.env.VITE_OPEN_WEATHER_LAT;
const lon = import.meta.env.VITE_OPEN_WEATHER_LON;
const appId = import.meta.env.VITE_OPEN_WEATHER_KEY;
return fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${appId}&units=metric`
)
.then((res) => res.json())
.then((data) => {
return {
...data.weather[0],
...data.main,
visibility: data.visibility,
wind_speed: data.wind.speed,
wind_deg: data.wind.deg,
sunrise: data.sys.sunrise,
sunset: data.sys.sunset,
};
});
}
export const createWeather = (): Accessor<WeatherData | undefined> => {
const [weather, setWeather] = createSignal<WeatherData>();
fetchWeather().then(setWeather);
return weather;
};
+8
View File
@@ -0,0 +1,8 @@
import { createApi } from 'unsplash-js';
const UNSPLASH_ACCESS_KEY = import.meta.env.VITE_UNSPLASH_ACCESS_KEY;
export const unsplashApi = createApi({ accessKey: UNSPLASH_ACCESS_KEY, fetch });
export const getRandomBackground = () =>
unsplashApi.photos.getRandom({ count: 10, collectionIds: ['114728'] });
+20
View File
@@ -0,0 +1,20 @@
.AppListWrap {
display: flex;
flex-wrap: wrap;
gap: 2rem;
}
.Header {
margin-bottom: 1rem;
padding-bottom: 8px;
font-size: 2rem;
border-bottom: 1px solid var(--color-text);
text-shadow: 0 0 4px #000;
}
.AppList {
display: flex;
gap: 6px;
margin: 0 auto 2rem;
flex-wrap: wrap;
}
+25
View File
@@ -0,0 +1,25 @@
import { groupBy } from 'ramda';
import { AppDefinition } from '~/types';
import tagOrder from '../../data/tag-order.json';
import { Pill } from './Pill';
import style from './AppList.module.css';
export function AppList({ apps }: { apps: AppDefinition[] }) {
const grouped = groupBy((app) => app.tags, apps);
return (
<div class={style.AppListWrap}>
{tagOrder.map((tag) => (
<div>
<h2 class={style.Header}>{tag}</h2>
<div class={style.AppList}>
{grouped[tag]!.map((app, i) => (
<Pill {...app} />
))}
</div>
</div>
))}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
.Name {
display: flex;
gap: 1rem;
align-items: center;
}
.Icon {
max-height: 24px;
max-width: 24px;
}
+60
View File
@@ -0,0 +1,60 @@
import { Anchor, Tag, TagProps } from '@hope-ui/solid';
import { useSettings } from '~/state/SettingsProvider';
import { AppDefinition } from '~/types';
import tagOrder from '../../data/tag-order.json';
import { getAppIcon } from '~/api/get-app-icon';
import style from './AppTable.module.css';
export function AppTable({ apps }: { apps: AppDefinition[] }) {
const { appSettings } = useSettings();
const TAG_TO_COLOR: Record<string, TagProps['colorScheme']> = {
media: 'primary',
productivity: 'accent',
infra: 'neutral',
'smart home': 'success',
'*arr': 'danger',
social: 'warning',
};
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Tags</th>
{appSettings.showLocations && <th>Location</th>}
<th>Shortuct</th>
</tr>
</thead>
<tbody>
{apps
.sort((tagA, tagB) => tagOrder.indexOf(tagA.tags) - tagOrder.indexOf(tagB.tags))
.map((app) => (
<tr>
<td>
<a href={app.url} target="_blank" rel="noopener noreferrer" class={style.Name}>
<img class={style.Icon} src={getAppIcon(app.icon)} alt={app.name} />
{app.name}
</a>
</td>
<td>
<a href={app.url} target="_blank" rel="noopener noreferrer">
{app.url.split('://')[1]}
</a>
</td>
<td>
{app.tags.split(',').map((tag) => (
<Tag colorScheme={TAG_TO_COLOR[tag]}>{tag}</Tag>
))}
</td>
{appSettings.showLocations && <td>{app.location}</td>}
<td>{app.shortcut}</td>
</tr>
))}
</tbody>
</table>
);
}
+22
View File
@@ -0,0 +1,22 @@
.Background {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-size: cover;
z-index: -1;
object-fit: cover;
}
.Overlay {
position: absolute;
content: "";
top: 0;
left: 0;
width: 100vw;
height: 100vh;
backdrop-filter: blur(10px);
pointer-events: none;
z-index: -1;
}
+64
View File
@@ -0,0 +1,64 @@
import { Random } from 'unsplash-js/dist/methods/photos/types';
import { createLocalStore } from '~/lib/create-local-store';
import { random } from '~/lib/math';
import { getRandomBackground, unsplashApi } from '~/api/unsplash';
import style from './Background.module.css';
import { BACKGROUND_FETCH_MINS } from '~/config';
import { createEffect, createMemo, createSignal } from 'solid-js';
type BackgroundStorage = {
fetched_at: number;
backgrounds: Random[] | null;
};
export function Background() {
const [backgrounds, setBackgrounds] = createLocalStore<BackgroundStorage>(
'start-page-background',
{
fetched_at: 0,
backgrounds: null,
}
);
const shouldFetch =
!backgrounds?.backgrounds ||
backgrounds.fetched_at < new Date().getTime() - 1000 * 60 * BACKGROUND_FETCH_MINS;
if (shouldFetch) {
getRandomBackground().then((r) =>
setBackgrounds({
fetched_at: new Date().getTime(),
backgrounds: (r?.response as Random[]) ?? null,
})
);
}
const backgroundUrls = createMemo(() => {
const backgroundLength = backgrounds?.backgrounds?.length ?? 0;
const background = random(backgroundLength - 1);
return backgrounds?.backgrounds?.[background].urls;
});
const [fullLoaded, setFullLoaded] = createSignal(false);
createEffect(() => {
if (!backgroundUrls()) {
return null;
}
var curImg = new Image();
curImg.src = backgroundUrls()!.full;
curImg.onload = () => setFullLoaded(true);
});
return (
<>
<img
class={style.Background}
src={fullLoaded() ? backgroundUrls()?.full : backgroundUrls()?.thumb}
/>
{!fullLoaded() ? <div class={style.Overlay}></div> : null}
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
.Box {
background-color: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(10px);
border: 2px solid rgba(30, 30, 30, 1);
color: var(--color-text);
transition: background-color 0.2s ease-in-out;
}
.Hoverable:hover, .Hoverable:focus-within {
background-color: rgba(50, 50, 50, 0.85);
}
+23
View File
@@ -0,0 +1,23 @@
import { JSX } from 'solid-js';
import style from './Box.module.css';
type BoxProps = {
children: JSX.Element;
className?: string;
disableHover?: boolean;
};
export function Box(props: BoxProps) {
return (
<div
classList={{
[props.className ?? '']: Boolean(props.className),
[style.Box]: true,
[style.Hoverable]: !props.disableHover,
}}
>
{props.children}
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
.Date {
font-family: nova mono;
font-size: 3rem;
letter-spacing: -2px;
text-shadow: 0 0 4px #000;
}
+11
View File
@@ -0,0 +1,11 @@
import { getDate } from '~/lib/date';
import style from './Date.module.css';
export function DateView() {
return (
<>
<h1 class={style.Date}>{getDate()}</h1>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
.Logo {
font-family: "Nova Mono";
color: #aaa;
text-shadow: 1px 1px 1px #000;
letter-spacing: -2px;
font-size: 2rem;
}
+5
View File
@@ -0,0 +1,5 @@
import style from './Logo.module.css';
export function Logo() {
return <h1 class={style.Logo}>gar.dev</h1>;
}
+51
View File
@@ -0,0 +1,51 @@
.Pill {
position: relative;
overflow: hidden;
overflow-clip-margin: -10px;
}
.Pill > a {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
width: 160px;
height: 160px;
padding: 20px;
font-size: 1.2rem;
text-decoration: none;
border: 2px solid rgba(30, 30, 30, 1);
color: var(--color-text);
}
.Icon {
max-height: 24px;
max-width: 24px;
}
.Url {
font-size: .85rem;
color: var(--color-secondary);
}
.Text {
display: flex;
flex-direction: column;
gap: .3rem;
align-items: flex-start;
}
.Name {
line-height: 1.3rem;
}
.Index {
position: absolute;
bottom: -10px;
right: -10px;
font-size: 120px;
color: rgba(90, 90, 90, 1);
letter-spacing: -4px;
font-family: "Nova Mono";
}
+26
View File
@@ -0,0 +1,26 @@
import { getAppIcon } from '~/api/get-app-icon';
import { AppDefinition } from '~/types';
import { Box } from './Box';
import { useSettings } from '~/state/SettingsProvider';
import style from './Pill.module.css';
export type PillProps = AppDefinition;
export function Pill({ url, name, icon, shortcut, location }: PillProps) {
const { appSettings } = useSettings();
return (
<Box className={style.Pill}>
<a href={url} target="_blank" rel="noopener noreferrer">
<div class={style.Text}>
<span class={style.Name}>{name}</span>
<span class={style.Url}>{url.split('://')[1].split('/')[0]}</span>
{appSettings.showLocations && location && <span class={style.Url}>{location}</span>}
</div>
<img class={style.Icon} src={getAppIcon(icon)} alt={name} />
{shortcut && <span class={style.Index}>{shortcut}</span>}
</a>
</Box>
);
}
+19
View File
@@ -0,0 +1,19 @@
.Search {
padding: 10px 20px;
width: 300px;
border: 0;
font-size: 1rem;
background-color: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(10px);
border: 2px solid rgba(30, 30, 30, 1);
color: var(--color-text);
transition: background-color 0.2s ease-in-out;
}
.Search:focus-within {
outline: 0;
}
.Search:focus-within, .Search:hover {
background-color: rgba(50, 50, 50, 0.85);
}
+24
View File
@@ -0,0 +1,24 @@
import { createSignal } from 'solid-js';
import style from './Search.module.css';
export function Search() {
const [term, setTerm] = createSignal('');
return (
<input
id="search"
class={style.Search}
type="text"
placeholder="Search kagi..."
value={term()}
onInput={(e) => setTerm(e.target.value)}
onKeyPress={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
window.open(`https://kagi.com/search?q=${term()}`, '_blank', 'noreferrer');
setTerm('');
}
}}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalHeader,
ModalBody,
Checkbox,
ModalFooter,
Button,
Flex,
} from '@hope-ui/solid';
import { useSettings } from '~/state/SettingsProvider';
type SettingsModalProps = {
opened: boolean;
onClose(): void;
};
export function SettingsModal(props: SettingsModalProps) {
const { appSettings, setAppSettings } = useSettings();
return (
<Modal opened={props.opened} onClose={props.onClose}>
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalHeader>Settings</ModalHeader>
<ModalBody>
<Flex direction="column" gap=".5rem">
<Checkbox
checked={appSettings.showLocations}
onChange={(e) =>
setAppSettings((prev) => ({ ...prev, showLocations: (e.target as any).checked }))
}
>
Show locations
</Checkbox>
<Checkbox
checked={appSettings.viewAsTable}
onChange={(e) =>
setAppSettings((prev) => ({ ...prev, viewAsTable: (e.target as any).checked }))
}
>
Table View
</Checkbox>
</Flex>
</ModalBody>
<ModalFooter>
<Button onClick={props.onClose}>Close</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
+39
View File
@@ -0,0 +1,39 @@
.Weather {
flex-grow: 1;
max-width: 300px;
min-width: 200px;
padding: 24px;
display: flex;
flex-direction: column;
gap: .5rem;
}
.FeelsLike {
display: flex;
flex-direction: column;
gap: .5rem;
margin-left: .5rem;
font-size: 1.3rem;
color: var(--color-bright);
}
.FeelsLike :last-child {
font-size: 1rem;
color: var(--color-secondary);
}
.Delimiter {
border-bottom: 1px solid var(--color-text);
padding-bottom: 1.2rem;
margin-bottom: .8rem;
}
.WeatherLine {
display: flex;
gap: .3rem;
align-items: center;
}
.WeatherIcon {
font-size: 3rem;
}
+45
View File
@@ -0,0 +1,45 @@
import { HiSolidArrowDown, HiSolidArrowUp } from 'solid-icons/hi';
import { WiHumidity, WiSunrise, WiSunset } from 'solid-icons/wi';
import { createMemo } from 'solid-js';
import { createWeather } from '~/api/open-weather';
import { getTime } from '~/lib/date';
import style from './Weather.module.css';
import { Box } from './Box';
export function Weather() {
const weather = createWeather();
const sunrise = createMemo(() => getTime((weather()?.sunrise ?? 0) * 1000));
const sunset = createMemo(() => getTime((weather()?.sunset ?? 0) * 1000));
return (
<Box className={style.Weather} disableHover>
{weather() ? (
<>
<div class={`${style.WeatherLine} ${style.Delimiter}`}>
<i class={`wi wi-owm-${weather()?.id} ${style.WeatherIcon}`}></i>
<div class={style.FeelsLike}>
<span>{weather()?.feels_like}&deg;</span>
<span>{weather()?.main}</span>
</div>
</div>
<div class={style.WeatherLine}>
<HiSolidArrowDown color="lightcoral" />
{weather()?.temp_min}&deg; <HiSolidArrowUp color="lightgreen" /> {weather()?.temp_max}
&deg;
<WiHumidity />
{weather()?.humidity}%<br />
</div>
<div class={style.WeatherLine}></div>
<div class={style.WeatherLine}>
<WiSunrise /> {sunrise()}
<WiSunset /> {sunset()}
</div>
</>
) : (
'Loading'
)}
</Box>
);
}
+1
View File
@@ -0,0 +1 @@
export const BACKGROUND_FETCH_MINS = 60;
+3
View File
@@ -0,0 +1,3 @@
import { mount, StartClient } from "solid-start/entry-client";
mount(() => <StartClient />, document);
+9
View File
@@ -0,0 +1,9 @@
import {
createHandler,
renderAsync,
StartServer,
} from "solid-start/entry-server";
export default createHandler(
renderAsync((event) => <StartServer event={event} />)
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="solid-start/env" />
+16
View File
@@ -0,0 +1,16 @@
import { createEffect } from 'solid-js';
import { SetStoreFunction, Store, createStore } from 'solid-js/store';
export function createLocalStore<T extends object>(
name: string,
init: T
): [Store<T>, SetStoreFunction<T>] {
const localState = typeof localStorage === 'undefined' ? undefined : localStorage.getItem(name);
const [state, setState] = createStore<T>(localState ? JSON.parse(localState) : init);
createEffect(() => {
localStorage.setItem(name, JSON.stringify(state));
});
return [state, setState];
}
+6
View File
@@ -0,0 +1,6 @@
import { format } from 'date-fns';
export const getTime = (ts: number) =>
new Date(ts).toTimeString().split(' ')[0].split(':').slice(0, 2).join(':');
export const getDate = () => format(new Date(), 'EEEE, dd MMMM yyyy');
+1
View File
@@ -0,0 +1 @@
export const random = (n: number) => Math.floor(Math.random() * n) + 1;
+61
View File
@@ -0,0 +1,61 @@
:root {
--color-text: #bbb;
--color-secondary: #777;
--color-bright: #eee;
}
body {
font-family: 'Helvetica Neue', sans-serif;
}
html,
main {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body,
main {
color: var(--color-text);
}
.scroll-wrapper {
height: 100vh;
overflow: scroll;
}
main {
width: 90vw;
max-width: 1394px;
margin: 0 auto;
}
main {
padding: 20px;
}
table {
width: 100%;
background-color: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(10px);
border: 2px solid rgba(30, 30, 30, 1);
color: var(--color-text);
}
th {
text-align: left;
font-weight: bold;
}
th, td {
padding: 1rem 1.5rem;
}
tr {
border-bottom: 1px solid black;
}
a:hover, a:focus {
text-decoration: underline;
}
+52
View File
@@ -0,0 +1,52 @@
// @refresh reload
import { HopeProvider } from '@hope-ui/solid';
import { Suspense } from 'solid-js';
import {
Body,
ErrorBoundary,
FileRoutes,
Head,
Html,
Meta,
Routes,
Scripts,
Title,
} from 'solid-start';
import './styles/weather-icons.min.css';
import './styles/reset.css';
import './root.css';
import { SettingsProvider } from './state/SettingsProvider';
export default function Root() {
return (
<Html lang="en">
<Head>
<Title>gar.dev</Title>
<Meta charset="utf-8" />
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Bad+Script&family=Fragment+Mono:ital@0;1&family=Nova+Mono&family=Raleway:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&family=Share+Tech+Mono&display=swap"
rel="stylesheet"
/>
</Head>
<Body>
<HopeProvider config={{ initialColorMode: 'dark' }}>
<SettingsProvider>
<div class="scroll-wrapper">
<Suspense>
<ErrorBoundary>
<Routes>
<FileRoutes />
</Routes>
</ErrorBoundary>
</Suspense>
</div>
</SettingsProvider>
<Scripts />
</HopeProvider>
</Body>
</Html>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Title } from "solid-start";
import { HttpStatusCode } from "solid-start/server";
export default function NotFound() {
return (
<main>
<Title>Not Found</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p>
Visit{" "}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{" "}
to learn how to build SolidStart apps.
</p>
</main>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { createDisclosure } from '@hope-ui/solid';
import { HiSolidCog } from 'solid-icons/hi';
import { onCleanup, onMount } from 'solid-js';
import { Title } from 'solid-start';
import { AppList } from '~/components/AppList';
import { AppTable } from '~/components/AppTable';
import { Background } from '~/components/Background';
import { DateView } from '~/components/Date';
import { Logo } from '~/components/Logo';
import { Search } from '~/components/Search';
import { SettingsModal } from '~/components/SettingsModal';
import { Weather } from '~/components/Weather';
import { useSettings } from '~/state/SettingsProvider';
import data from '../../data/apps.json';
function handleKeypress(e: KeyboardEvent) {
if (e.key === '/') {
document.getElementById('search')?.focus();
e.preventDefault();
}
const app = data.find((app) => app.shortcut === e.key);
if (app) {
window.open(app.url);
}
}
export default function Home() {
const { appSettings } = useSettings();
const {
isOpen: isSettingsOpen,
onOpen: openSettings,
onClose: closeSettings,
} = createDisclosure();
onMount(() => {
parent.addEventListener('keypress', handleKeypress);
});
onCleanup(() => {
parent.removeEventListener('keypress', handleKeypress);
});
return (
<main>
<Title>gar.dev</Title>
<Background />
<HiSolidCog onClick={openSettings} size={24} />
<div
style={{
display: 'flex',
'flex-wrap': 'wrap',
gap: '2rem',
'margin-top': '2rem',
'justify-content': 'space-between',
'align-items': 'center',
'margin-bottom': appSettings.viewAsTable ? '20px' : '80px',
}}
>
<div>
<div
style={{
display: 'flex',
'align-items': 'center',
'margin-bottom': '2rem',
gap: '2rem',
'flex-wrap': 'wrap',
}}
>
<DateView />
<Logo />
</div>
<Search />
</div>
<Weather />
</div>
{appSettings.viewAsTable ? <AppTable apps={data} /> : <AppList apps={data} />}
<SettingsModal opened={isSettingsOpen()} onClose={closeSettings} />
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { JSX, createContext, useContext } from 'solid-js';
import { SetStoreFunction } from 'solid-js/store';
import { createLocalStore } from '~/lib/create-local-store';
type Settings = { showLocations: boolean; viewAsTable: boolean };
type SettingsContextType = {
appSettings: Settings;
setAppSettings: SetStoreFunction<Settings>;
};
const SettingsContext = createContext<SettingsContextType>({
appSettings: { showLocations: false, viewAsTable: false },
setAppSettings: () => {},
});
export function SettingsProvider(props: { children: JSX.Element }) {
const [appSettings, setAppSettings] = createLocalStore<Settings>('start-page-app-settings', {
showLocations: false,
viewAsTable: false,
});
return (
<SettingsContext.Provider value={{ appSettings, setAppSettings }}>
{props.children}
</SettingsContext.Provider>
);
}
export function useSettings() {
return useContext(SettingsContext);
}
+48
View File
@@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
export type AppDefinition = {
name: string;
url: string;
icon: string;
tags: string;
location?: string;
shortcut?: string;
};
export type WeatherData = {
id: string;
main: string;
description: string;
icon: string;
temp: number;
feels_like: number;
temp_min: number;
temp_max: number;
pressure: number;
humidity: number;
visibility: number;
wind_speed: number;
wind_deg: number;
sunrise: number;
sunset: number;
};