Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results

Target

Select target project
  • fsi/idm/self-service-frontend
1 result
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results
Show changes
Commits on Source (4)
Showing
with 1838 additions and 5361 deletions
......@@ -83,6 +83,7 @@ module.exports = {
"eol-last": "error",
"eqeqeq": "error",
"guard-for-in": "error",
"import/no-unresolved": ["error", { ignore: ["\\.svg\\?react$"] }],
indent: ["error", "tab", {
"SwitchCase": 1,
}],
......
# Self Service Frontend
# Self-Service Frontend
The frontend of the new faculty-wide User Self-Service, FSI Self-Service (FSI = Fachschaften der Fakultät für Informatik)!
The FSI Self-Service allows you to view and, partially, change some basic information about your FSI account, such as your name, major, and assigned groups.
Notably, it also allows you to link your [Discord](https://fsi.rub.de/links/)-Account to your FSI account, granting you extended access to our Discord servers.
It is planned to be available at [account.fsi.rub.de](https://account.fsi.rub.de/), but due to internal scheduling reasons, it unfortunately isn't live yet.
The backend is available [here](https://gitlab.ruhr-uni-bochum.de/fsi/idm/self-service-backend) (you may need to be logged in to view this link).
## How it works
- Single-page application powered by [React](https://react.dev/) and [TypeScript](https://www.typescriptlang.org/)
- Styling using [Tailwind CSS](https://tailwindcss.com/)
- [React Hook Form](https://react-hook-form.com/) for interative forms and input validation
- Keeping track of state using [zustand](https://github.com/pmndrs/zustand)
- Translation support using [FormatJS](https://formatjs.io/)
- API Response validation using the slim [Valibot](https://valibot.dev/) library
- [Vite](https://vitejs.dev/) as the build system, bundler, and tree-shaker 🌴
- Code formatting and linting using [Prettier](https://prettier.io/) and [ESLint](https://eslint.org/)
- Native [View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) thanks to [React Router](https://reactrouter.com/)
## Showcase
### Dark Mode Toggle
![Short video showcasing the dark mode toggle](readme_files/dark_mode.mp4)
### Language Toggle
![Short video demonstrating toggling the language between English and German](readme_files/language_toggle.mp4)
### View Transitions
![Short video showcasing animated navigation powered by the view transitions API](readme_files/view_transitions.mp4)
### Mobile Navigation
![Example video of using the mobile navigation hamburger/sidebar](readme_files/mobile_navigation.mp4)
This diff is collapsed.
......@@ -11,38 +11,38 @@
"format": "prettier --write ."
},
"dependencies": {
"classnames": "^2.3.2",
"classnames": "^2.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.11",
"react-hook-form": "^7.47.0",
"react-intl": "^6.5.0",
"react-router-dom": "^6.17.0",
"react-select": "^5.7.7",
"valibot": "^0.19.0",
"zustand": "^4.4.3"
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.49.3",
"react-intl": "^6.6.1",
"react-router-dom": "^6.21.3",
"react-select": "^5.8.0",
"valibot": "^0.26.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@vitejs/plugin-react": "^4.1.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.51.0",
"eslint-plugin-import": "^2.28.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.31",
"postcss-nesting": "^12.0.1",
"prettier": "^3.0.3",
"rollup-plugin-visualizer": "^5.9.2",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"unplugin-inject-preload": "^1.1.1",
"vite": "^4.4.11",
"vite-plugin-svgr": "^3.3.0"
"postcss": "^8.4.33",
"postcss-nesting": "^12.0.2",
"prettier": "^3.2.4",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"unplugin-inject-preload": "^2.0.0",
"vite": "^5.0.11",
"vite-plugin-svgr": "^4.2.0"
}
}
File added
File added
File added
File added
import { ErrorBoundary } from "react-error-boundary";
import Logger from "../Logger";
import { ReactComponent as Icon } from "../icons/WheelchairMove.svg";
import Icon from "../icons/WheelchairMove.svg?react";
import Button from "./widgets/Button";
import { useEffect, type ErrorInfo } from "react";
import { STYLES } from "../constants";
......@@ -58,7 +58,8 @@ function ErrorDetails({ error }: { error: Error }) {
return (
<div className="grid place-items-center">
<div className="max-w-full overflow-x-auto">
<details className={`w-fit text-sm mt-4 bg-slate-100 dark:bg-slate-900 border ${STYLES.BORDER_COLOR} rounded-md`}>
<details
className={`w-fit text-sm mt-4 bg-slate-100 dark:bg-slate-900 border ${STYLES.BORDER_COLOR} rounded-md`}>
<summary className="p-4 cursor-pointer">{errorMessage}</summary>
<pre className={`p-4 border-t ${STYLES.BORDER_COLOR}`}>{error.stack}</pre>
</details>
......
import classNames from "classnames";
import { NavLink } from "react-router-dom";
import type { WithClassName } from "../../utils/types";
import { ReactComponent as ArrowUpRightFromSquare } from "../../icons/ArrowUpRightFromSquare.svg";
import ArrowUpRightFromSquare from "../../icons/ArrowUpRightFromSquare.svg?react";
import useIntlMessages from "../i18n/useIntlMessages";
import { SERVICES_LIST_URL } from "../../constants";
import { useLoginStore } from "../../data/state/login";
......
import classNames from "classnames";
import { ReactComponent as Bars } from "../../icons/Bars.svg";
import { ReactComponent as XMark } from "../../icons/XMark.svg";
import Bars from "../../icons/Bars.svg?react";
import XMark from "../../icons/XMark.svg?react";
import { useNavigationStore } from "../../data/state/navigation";
export default function NavigationHamburger({ className, children, ...props }: JSX.IntrinsicElements["button"]) {
......
......@@ -84,8 +84,10 @@ export default function WidgetsPage() {
<Button loading outlined>
Loading Outlined
</Button>
<Button href="https://google.com/">Link</Button>
<Button loading href="https://google.com/">
<Button as="a" href="https://google.com/">
Link
</Button>
<Button as="a" loading href="https://google.com/">
Loading Link
</Button>
</div>
......
......@@ -3,14 +3,16 @@ import Spinner from "./Spinner";
type ButtonStyle = "info" | "success" | "warn" | "danger" | "link";
interface BaseProps {
type ButtonOrLinkProps =
| (React.ButtonHTMLAttributes<HTMLButtonElement> & { as?: "button" })
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" });
type Props = ButtonOrLinkProps & {
buttonStyle?: ButtonStyle;
small?: boolean;
outlined?: boolean;
loading?: boolean;
}
type Props = BaseProps & (JSX.IntrinsicElements["button"] | JSX.IntrinsicElements["a"]);
};
const buttonStyleToClass: Record<ButtonStyle, string> = {
info: "text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-800 dark:hover:bg-blue-900",
......@@ -65,13 +67,29 @@ export default function Button({ buttonStyle, small, outlined, loading, classNam
props.type ??= "button";
}
const Element = "href" in props ? "a" : "button";
const commonProps = {
className: classes,
disabled: disabled || loading,
};
return (
// @ts-expect-error: Can't be bothered debugging these types, I already wasted 15 minutes on this
<Element className={classes} disabled={disabled || loading} {...props}>
const commonChildren = (
<>
{loading && <Spinner className="w-4 h-4 fill-current" />}
{children}
</Element>
</>
);
// Element creation is separated like this for type safety
if (props.as === "a") {
return (
<a {...props} {...commonProps}>
{commonChildren}
</a>
);
}
return (
<button {...props} {...commonProps}>
{commonChildren}
</button>
);
}
import { useLanguageStore } from "../../data/state/language";
import { ReactComponent as Icon } from "../../icons/Language.svg";
import Icon from "../../icons/Language.svg?react";
export default function LanguageSelect() {
const { language, setLanguage } = useLanguageStore();
......@@ -8,7 +8,8 @@ export default function LanguageSelect() {
setLanguage(language === "de" ? "en" : "de");
};
const iconClasses = "w-8 h-8 transition-colors " + "fill-slate-600 hover:fill-black dark:fill-slate-400 dark:hover:fill-white";
const iconClasses =
"w-8 h-8 transition-colors " + "fill-slate-600 hover:fill-black dark:fill-slate-400 dark:hover:fill-white";
return (
<button
......
import classNames from "classnames";
import { ReactComponent as SpinnerIcon } from "../../icons/Spinner.svg";
import SpinnerIcon from "../../icons/Spinner.svg?react";
type Props = {
className?: string;
......
import classNames from "classnames";
import type { LinkProps } from "react-router-dom";
import { Link } from "react-router-dom";
import { ReactComponent as ArrowUpRightFromSquare } from "../../icons/ArrowUpRightFromSquare.svg";
import ArrowUpRightFromSquare from "../../icons/ArrowUpRightFromSquare.svg?react";
import { STYLES } from "../../constants";
/* eslint-disable no-mixed-spaces-and-tabs */
......@@ -28,7 +28,11 @@ export default function TextLink({ className, children, to, ...props }: TextLink
if (isExternal) {
return (
<a href={to} target="_blank" rel="noreferrer" className={`inline-flex flex-row items-baseline gap-0.5 ${classes}`}>
<a
href={to}
target="_blank"
rel="noreferrer"
className={`inline-flex flex-row items-baseline gap-0.5 ${classes}`}>
<span>{children}</span>
<ArrowUpRightFromSquare className="w-3 h-3 fill-current" />
</a>
......
import { useThemeStore } from "../../data/state/theme";
import { ReactComponent as Sun } from "../../icons/Sun.svg";
import { ReactComponent as Moon } from "../../icons/Moon.svg";
import Sun from "../../icons/Sun.svg?react";
import Moon from "../../icons/Moon.svg?react";
export default function ThemeToggle() {
const [isDarkMode, setDarkMode] = useThemeStore(s => [s.isDarkMode, s.setDarkMode]);
......@@ -9,8 +9,7 @@ export default function ThemeToggle() {
"w-12 h-6 flex items-center p-0.5 rounded-full bg-slate-200 dark:bg-blue-600 overflow-hidden";
const innerContainerClasses =
"transition-all dark:translate-x-full flex flex-row items-center gap-1.5 " +
"text-slate-600 dark:text-slate-300";
"transition-all dark:translate-x-full flex flex-row items-center gap-1.5 " + "text-slate-600 dark:text-slate-300";
const toggleHandleClasses = "h-5 w-5 bg-white border rounded-full border-gray-300";
......
......@@ -4,7 +4,7 @@ import { EventTypes } from "../../../data/events/eventTypes";
import Message from "../../i18n/Message";
import { STYLES } from "../../../constants";
import Button from "../Button";
import { ReactComponent as EditIcon } from "../../../icons/PenToSquare.svg";
import EditIcon from "../../../icons/PenToSquare.svg?react";
interface Props {
value: string;
......
......@@ -97,7 +97,7 @@ function Guild({ id, name, iconHash, memberCount, inviteCode, roles, isMember }:
<Message id="widgets.discord.joined" />
</Button>
) : (
<Button small href={DISCORD_INVITE_URL(inviteCode)}>
<Button as="a" small href={DISCORD_INVITE_URL(inviteCode)}>
<Message id="widgets.discord.join" />
</Button>
)}
......
......@@ -15,7 +15,6 @@ export function H2(props: JSX.IntrinsicElements["h2"]) {
return <Heading level={2} baseClasses="font-bold text-2xl mt-8 mb-4 first:mt-3" {...props} />;
}
export function H3(props: JSX.IntrinsicElements["h1"]) {
return <Heading level={3} baseClasses="font-bold text-xl mt-8 mb-4 first:mt-2" {...props} />;
}