diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..125cd198c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.git diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8bc8a13f5..9cd1d766b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -41,12 +41,18 @@ jobs: # If source files changed but packages didn't, rebuild from a prior cache. restore-keys: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - run: yarn install --frozen-lockfile - - run: yarn export + - run: yarn build - name: Cache build output uses: actions/cache@v2 id: restore-build with: - path: ./out/ + path: | + ./next.config.js + ./pages/ + ./public/ + ./.next/static/ + ./.next/standalone/ + ./packages.jsan key: ${{ github.sha }} docker: @@ -61,16 +67,20 @@ jobs: - uses: actions/cache@v2 id: restore-build with: - path: ./out/ + path: | + ./next.config.js + ./pages/ + ./public/ + ./.next/static/ + ./.next/standalone/ + ./packages.jsan key: ${{ github.sha }} - name: Docker meta id: meta uses: docker/metadata-action@v4 with: # list of Docker images to use as base name for tags - images: | - ajnart/homarr - ghcr.io/ajnart/homarr + images: ghcr.io/${{ github.repository }} # generate Docker tags based on the following events/attributes tags: | type=raw,value=latest,enable={{is_default_branch}} @@ -79,12 +89,6 @@ jobs: uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v2 diff --git a/Dockerfile b/Dockerfile index d59093471..5d13c04aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,2 +1,20 @@ -FROM nginx:alpine -COPY ./out /usr/share/nginx/html +FROM node:16-alpine +WORKDIR /app +ENV NODE_ENV production +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY /next.config.js ./ +COPY /public ./public +COPY /package.json ./package.json + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --chown=nextjs:nodejs /.next/standalone ./ +COPY --chown=nextjs:nodejs /.next/static ./.next/static + +USER nextjs +EXPOSE 7575 +ENV PORT 7575 +VOLUME /app/data/configs +CMD ["node", "server.js"] diff --git a/components/AppShelf/AddAppShelfItem.tsx b/components/AppShelf/AddAppShelfItem.tsx index 37eac451e..5f42d2af2 100644 --- a/components/AppShelf/AddAppShelfItem.tsx +++ b/components/AppShelf/AddAppShelfItem.tsx @@ -1,5 +1,4 @@ import { - useMantineTheme, Modal, Center, Group, @@ -21,9 +20,7 @@ import { ServiceTypeList } from '../../tools/types'; import { AppShelfItemWrapper } from './AppShelfItemWrapper'; export default function AddItemShelfItem(props: any) { - const { addService } = useConfig(); const [opened, setOpened] = useState(false); - const theme = useMantineTheme(); return ( <> { + .catch(() => { // Do nothing }); @@ -92,7 +89,7 @@ function MatchIcon(name: string, form: any) { export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & any) { const { setOpened } = props; - const { addService, config, setConfig } = useConfig(); + const { config, setConfig } = useConfig(); const [isLoading, setLoading] = useState(false); const form = useForm({ @@ -104,7 +101,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & apiKey: props.apiKey ?? (undefined as unknown as string), }, validate: { - apiKey: (value: string) => null, + apiKey: () => null, // Validate icon with a regex icon: (value: string) => { if (!value.match(/^https?:\/\/.+\.(png|jpg|jpeg|gif)$/)) { @@ -143,7 +140,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } & }), }); } else { - addService(form.values); + setConfig({ + ...config, + services: [...config.services, form.values], + }); } setOpened(false); form.reset(); diff --git a/components/AppShelf/AppShelf.tsx b/components/AppShelf/AppShelf.tsx index fb0b23f7e..af5f85340 100644 --- a/components/AppShelf/AppShelf.tsx +++ b/components/AppShelf/AppShelf.tsx @@ -1,35 +1,18 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { motion } from 'framer-motion'; -import { - Text, - AspectRatio, - SimpleGrid, - Card, - useMantineTheme, - Image, - Group, - Space, -} from '@mantine/core'; +import { Text, AspectRatio, SimpleGrid, Card, Image, Group, Space } from '@mantine/core'; import { useConfig } from '../../tools/state'; import { serviceItem } from '../../tools/types'; import AddItemShelfItem from './AddAppShelfItem'; import { AppShelfItemWrapper } from './AppShelfItemWrapper'; import AppShelfMenu from './AppShelfMenu'; -const AppShelf = (props: any) => { - const { config, addService, removeService, setConfig } = useConfig(); - - /* A hook that is used to load the config from local storage. */ - useEffect(() => { - const localConfig = localStorage.getItem('config'); - if (localConfig) { - setConfig(JSON.parse(localConfig)); - } - }, []); +const AppShelf = () => { + const { config } = useConfig(); return ( - {config.services.map((service, i) => ( + {config.services.map((service) => ( ))} @@ -39,16 +22,14 @@ const AppShelf = (props: any) => { export function AppShelfItem(props: any) { const { service }: { service: serviceItem } = props; - const theme = useMantineTheme(); - const { removeService } = useConfig(); const [hovering, setHovering] = useState(false); return ( { + onHoverStart={() => { setHovering(true); }} - onHoverEnd={(e) => { + onHoverEnd={() => { setHovering(false); }} > @@ -79,7 +60,7 @@ export function AppShelfItem(props: any) { opacity: hovering ? 1 : 0, }} > - + diff --git a/components/AppShelf/AppShelfMenu.tsx b/components/AppShelf/AppShelfMenu.tsx index 95042ddf1..ae905dfbf 100644 --- a/components/AppShelf/AppShelfMenu.tsx +++ b/components/AppShelf/AppShelfMenu.tsx @@ -2,10 +2,12 @@ import { Menu, Modal, Text } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { useState } from 'react'; import { Check, Edit, Trash } from 'tabler-icons-react'; +import { useConfig } from '../../tools/state'; import { AddAppShelfItemForm } from './AddAppShelfItem'; export default function AppShelfMenu(props: any) { - const { service, removeitem: removeItem } = props; + const { service } = props; + const { config, setConfig } = useConfig(); const [opened, setOpened] = useState(false); return ( <> @@ -40,7 +42,10 @@ export default function AppShelfMenu(props: any) { { - removeItem(service.name); + setConfig({ + ...config, + services: config.services.filter((s) => s.name !== service.name), + }); showNotification({ autoClose: 5000, title: ( diff --git a/components/Config/ConfigChanger.tsx b/components/Config/ConfigChanger.tsx new file mode 100644 index 000000000..5b8836803 --- /dev/null +++ b/components/Config/ConfigChanger.tsx @@ -0,0 +1,37 @@ +import { Center, Loader, Select, Tooltip } from '@mantine/core'; +import { setCookies } from 'cookies-next'; +import { useEffect, useState } from 'react'; +import { useConfig } from '../../tools/state'; + +export default function ConfigChanger() { + const { config, loadConfig, setConfig, getConfigs } = useConfig(); + const [configList, setConfigList] = useState([] as string[]); + useEffect(() => { + getConfigs().then((configs) => setConfigList(configs)); + // setConfig(initialConfig); + }, [config]); + // If configlist is empty, return a loading indicator + if (configList.length === 0) { + return ( +
+ + + +
+ ); + } + return ( + + ); +} diff --git a/components/SearchBar/SearchBar.tsx b/components/SearchBar/SearchBar.tsx index efe88f806..fec0dffe4 100644 --- a/components/SearchBar/SearchBar.tsx +++ b/components/SearchBar/SearchBar.tsx @@ -48,7 +48,7 @@ export default function SearchBar(props: any) { if (isYoutube) { window.open(`https://www.youtube.com/results?search_query=${querry.substring(3)}`); } else if (isTorrent) { - window.open(`https://thepiratebay.org/search.php?q=${querry.substring(3)}`); + window.open(`https://bitsearch.to/search?q=${querry.substring(3)}`); } else { window.open(`${querryUrl}${values.querry}`); } diff --git a/components/Settings/SettingsMenu.tsx b/components/Settings/SettingsMenu.tsx index 47a41fee1..b8fed02eb 100644 --- a/components/Settings/SettingsMenu.tsx +++ b/components/Settings/SettingsMenu.tsx @@ -16,6 +16,7 @@ import { AlertCircle, Settings as SettingsIcon } from 'tabler-icons-react'; import { CURRENT_VERSION, REPO_URL } from '../../data/constants'; import { useConfig } from '../../tools/state'; import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch'; +import ConfigChanger from '../Config/ConfigChanger'; import SaveConfigComponent from '../Config/SaveConfig'; import ModuleEnabler from './ModuleEnabler'; @@ -28,7 +29,6 @@ function SettingsMenu(props: any) { { label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' }, { label: 'Bing', value: 'https://bing.com/search?q=' }, ]; - return ( match.value === config.settings.searchUrl)?.value || 'Google' + matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Google' } onChange={ // Set config.settings.searchUrl to the value of the selected item @@ -79,6 +79,7 @@ function SettingsMenu(props: any) { + 0 && } setOpened(false)} opened={opened} diff --git a/components/modules/calendar/MediaDisplay.tsx b/components/modules/calendar/MediaDisplay.tsx index dcea62458..1cc876a3e 100644 --- a/components/modules/calendar/MediaDisplay.tsx +++ b/components/modules/calendar/MediaDisplay.tsx @@ -15,7 +15,14 @@ function MediaDisplay(props: { media: IMedia }) { const { media }: { media: IMedia } = props; return ( - {media.title} + {media.title} ({ diff --git a/components/modules/moduleWrapper.tsx b/components/modules/moduleWrapper.tsx index 91be8a7ff..52170c7be 100644 --- a/components/modules/moduleWrapper.tsx +++ b/components/modules/moduleWrapper.tsx @@ -20,7 +20,7 @@ export default function ModuleWrapper(props: any) { shadow="sm" style={{ // Make background color of the card depend on the theme - backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white', + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : 'white', }} > diff --git a/data/configs/cringe.json b/data/configs/cringe.json new file mode 100644 index 000000000..db308ffe9 --- /dev/null +++ b/data/configs/cringe.json @@ -0,0 +1,16 @@ +{ + "name": "cringe", + "services": [ + { + "type": "Other", + "name": "sonarr", + "icon": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/sonarr.png", + "url": "http://sonarr.tv/" + } + ], + "settings": { + "enabledModules": [], + "searchBar": true, + "searchUrl": "https://www.google.com/search?q=" + } +} \ No newline at end of file diff --git a/data/configs/default.json b/data/configs/default.json new file mode 100644 index 000000000..9e1d1669a --- /dev/null +++ b/data/configs/default.json @@ -0,0 +1,16 @@ +{ + "name": "default", + "services": [ + { + "type": "Other", + "name": "example", + "icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif", + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + } + ], + "settings": { + "searchBar": true, + "searchUrl": "https://www.google.com/search?q=", + "enabledModules": [] + } +} \ No newline at end of file diff --git a/package.json b/package.json index e0ee483b9..43337761e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@mantine/rte": "^4.2.4", "@mantine/spotlight": "^4.2.4", "@modulz/radix-icons": "^4.0.0", + "axios": "^0.27.2", "cookies-next": "^2.0.4", "dayjs": "^1.11.2", "framer-motion": "^6.3.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index bb54e75df..89887632d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -16,7 +16,7 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) { const toggleColorScheme = (value?: ColorScheme) => { const nextColorScheme = value || (colorScheme === 'dark' ? 'light' : 'dark'); setColorScheme(nextColorScheme); - setCookies('mantine-color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 }); + setCookies('color-scheme', nextColorScheme, { maxAge: 60 * 60 * 24 * 30 }); }; return ( @@ -50,5 +50,5 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) { } App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => ({ - colorScheme: getCookie('mantine-color-scheme', ctx) || 'light', + colorScheme: getCookie('color-scheme', ctx) || 'light', }); diff --git a/pages/api/configs/[slug].ts b/pages/api/configs/[slug].ts new file mode 100644 index 000000000..dfa8ed8d0 --- /dev/null +++ b/pages/api/configs/[slug].ts @@ -0,0 +1,61 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import fs from 'fs'; +import path from 'path'; + +function Put(req: NextApiRequest, res: NextApiResponse) { + // Get the slug of the request + const { slug } = req.query as { slug: string }; + // Get the body of the request + const { body }: { body: string } = req; + if (!slug || !body) { + res.status(400).json({ + error: 'Wrong request', + }); + } + // Save the body in the /data/config folder with the slug as filename + + fs.writeFileSync( + path.join('data/configs', `${slug}.json`), + JSON.stringify(body, null, 2), + 'utf8' + ); + return res.status(200).json({ + message: 'Configuration saved with success', + }); +} + +function Get(req: NextApiRequest, res: NextApiResponse) { + // Get the slug of the request + const { slug } = req.query as { slug: string }; + if (!slug) { + return res.status(400).json({ + message: 'Wrong request', + }); + } + // Loop over all the files in the /data/configs directory + const files = fs.readdirSync('data/configs'); + // Strip the .json extension from the file name + const configs = files.map((file) => file.replace('.json', '')); + // If the target is not in the list of files, return an error + if (!configs.includes(slug)) { + return res.status(404).json({ + message: 'Target not found', + }); + } + // Return the content of the file + return res.status(200).json(fs.readFileSync(path.join('data/configs', `${slug}.json`), 'utf8')); +} + +export default async (req: NextApiRequest, res: NextApiResponse) => { + // Filter out if the reuqest is a Put or a GET + if (req.method === 'PUT') { + return Put(req, res); + } + if (req.method === 'GET') { + return Get(req, res); + } + return res.status(405).json({ + statusCode: 405, + message: 'Method not allowed', + }); +}; diff --git a/pages/api/configs/index.ts b/pages/api/configs/index.ts new file mode 100644 index 000000000..78aabc4e8 --- /dev/null +++ b/pages/api/configs/index.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import fs from 'fs'; + +function Get(req: NextApiRequest, res: NextApiResponse) { + // Loop over all the files in the /data/configs directory + const files = fs.readdirSync('data/configs'); + // Strip the .json extension from the file name + const configs = files.map((file) => file.replace('.json', '')); + + return res.status(200).json(configs); +} + +export default async (req: NextApiRequest, res: NextApiResponse) => { + // Filter out if the reuqest is a POST or a GET + if (req.method === 'POST') { + return res.status(405).json({ + statusCode: 405, + message: 'Method not allowed', + }); + } + if (req.method === 'GET') { + return Get(req, res); + } + return res.status(405).json({ + statusCode: 405, + message: 'Method not allowed', + }); +}; diff --git a/pages/index.tsx b/pages/index.tsx index 7203f0f44..8ec813ffb 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,9 +1,57 @@ import { Group } from '@mantine/core'; +import { getCookie, setCookies } from 'cookies-next'; +import { GetServerSidePropsContext } from 'next'; +import path from 'path'; +import fs from 'fs'; +import { useEffect } from 'react'; import AppShelf from '../components/AppShelf/AppShelf'; import LoadConfigComponent from '../components/Config/LoadConfig'; import SearchBar from '../components/SearchBar/SearchBar'; +import { Config } from '../tools/types'; +import { useConfig } from '../tools/state'; -export default function HomePage() { +export async function getServerSideProps({ + req, + res, +}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> { + let cookie = getCookie('config-name', { req, res }); + if (!cookie) { + setCookies('config-name', 'default', { req, res, maxAge: 60 * 60 * 24 * 30 }); + cookie = 'default'; + } + // Check if the config file exists + const configPath = path.join(process.cwd(), 'data/configs', `${cookie}.json`); + if (!fs.existsSync(configPath)) { + return { + props: { + config: { + name: cookie.toString(), + services: [], + settings: { + enabledModules: [], + searchBar: true, + searchUrl: 'https://www.google.com/search?q=', + }, + }, + }, + }; + } + + const config = fs.readFileSync(configPath, 'utf8'); + // Print loaded config + return { + props: { + config: JSON.parse(config), + }, + }; +} + +export default function HomePage(props: any) { + const { config: initialConfig }: { config: Config } = props; + const { config, loadConfig, setConfig, getConfigs } = useConfig(); + useEffect(() => { + setConfig(initialConfig); + }, [initialConfig]); return ( <> diff --git a/pages/tryconfig.tsx b/pages/tryconfig.tsx new file mode 100644 index 000000000..6fb38734c --- /dev/null +++ b/pages/tryconfig.tsx @@ -0,0 +1,101 @@ +import { getCookie, setCookies } from 'cookies-next'; +import { GetServerSidePropsContext } from 'next/types'; +import fs from 'fs'; +import path from 'path'; +import { Button, JsonInput, Select, Space } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import { Config } from '../tools/types'; +import { useConfig } from '../tools/state'; + +export async function getServerSideProps({ + req, + res, +}: GetServerSidePropsContext): Promise<{ props: { config: Config } }> { + let cookie = getCookie('config-name', { req, res }); + if (!cookie) { + setCookies('config-name', 'default', { req, res, maxAge: 60 * 60 * 24 * 30 }); + cookie = 'default'; + } + // Check if the config file exists + const configPath = path.join(process.cwd(), 'data/configs', `${cookie}.json`); + if (!fs.existsSync(configPath)) { + return { + props: { + config: { + name: cookie.toString(), + services: [], + settings: { + enabledModules: [], + searchBar: true, + searchUrl: 'https://www.google.com/search?q=', + }, + }, + }, + }; + } + + const config = fs.readFileSync(configPath, 'utf8'); + // Print loaded config + return { + props: { + config: JSON.parse(config), + }, + }; +} + +export default function TryConfig(props: any) { + const { config: initialConfig }: { config: Config } = props; + const { config, loadConfig, setConfig, getConfigs } = useConfig(); + const [value, setValue] = useState(JSON.stringify(config, null, 2)); + const [configList, setConfigList] = useState([] as string[]); + useEffect(() => { + setValue(JSON.stringify(initialConfig, null, 2)); + setConfig(initialConfig); + }, [initialConfig]); + useEffect(() => { + setValue(JSON.stringify(config, null, 2)); + // setConfig(initialConfig); + }, [config]); + + return ( +
+

Try Config

+

+ This page is a demo of the config API. +

+

+ The config API is a way to store configuration data in a JSON file. +

+

+ Cookie loaded was {initialConfig.name} +

+ + + + +