Overview
The overview view is the main view of the app, i.e. the "homepage" of the app. In the case of Epic Fantasy Forge, all views are a sub-view of the overview view except for the login and onboarding views.
Structure
Navigation Bar
The navigationbar links to sub-views that can be displayed inside the container on the overview view. On small screens, the navigation bar is shown on the bottom. On larger screens, the navigation bar is shown on the left.
The navigation bar can be scrolled if all the links do not fit on the screen. Both the desktop and mobile navigation bars can be scrolled through the swipe gesture on touchscreens. On laptops with a touchpad, they can be scrolled with a similar gesture.
On computers with a mouse, the desktop navigation bar can be scrolled using the mouse wheel. The mobile navigation bar can be scrolled by holding Shift and then scrolling the mouse wheel. The reason Shift needs to be pressed whilst scrolling is because the scrolling is horizontal and not vertical. I had at first considered allowing the mobile navigation bar to be scrolled without holding Shift, however this required a lot of custom TypeScript code that it seemed doubtful if it is worth the additional complexity. Furthermore it would violate the usual pattern that horizontal scrolling is achieved by holding down Shift whilst scrolling the mouse wheel.
To let users know the navigation bars are scrollable, they are briefly animated to show the scrolling when the user views the overview view.
Search Bar
The search bar allows the user to search for strings in all of their world content, e.g. stories, locations, characters, etc.
For now, the search bar is not functional since there is nothing to search yet, i.e. we haven't yet implemented views such as the stories, locations or characters views.
The search is a standard case-insensitive search including all text and image names of the user's world. In future it would be nice to incorporate AI into the search for a better experience. For example, the user could search for something by providing a high-level description without needing the know the correct keywords to search for. This could also be useful for searching through images. For example, the user could search for "images with trees" and the AI would return all of the images that contain trees. This is not possible with a standard search unless the image name and/or metadata contain the correct keywords.
Notifications Button
The notification button allows the user to view any notifications from Epic Fantasy Forge. The notifications may include:
- AI image generation has completed
- World has been starred
- Milestone has been reached
Settings Button
The settings button allows the user to view the settings view. In the settings view the user may adjust app settings and update their profile.
Container
The container is used to display sub-views, e.g. the stories or characters view. Currently the container is completely empty as none of the sub-views have been implemented yet.
Tests
E2E Test
We will add a manual E2E test for the overview view to Qase. Add a new test case to the Common suite named Overview.
Automated Tests
Web
In the assets/test directory of your Phoenix project, create a new file named animation-test-utils.ts and populate it with the below content:
const animationDuration = 1000;
export const animationStepDuration = 100;
export const animationSteps = animationDuration / animationStepDuration * 2 + 1;
export enum Platform {
Desktop,
Mobile,
};
In the assets/test directory of your Phoenix project, create a new file named animations.test.ts and populate it with the below content:
import "@testing-library/jest-dom";
import {
animationStepDuration,
animationSteps,
Platform } from "./animation-test-utils";
import { fireEvent } from "@testing-library/dom";
import { initializeAnimations } from "../ts/animations";
describe("Animations", () => {
let desktopNavigationBar: HTMLElement;
let mobileNavigationBar: HTMLElement;
let requestAnimationFrameSpy: jest.SpyInstance;
let count = -animationStepDuration;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
document.body.innerHTML = `
<div id="desktop-sidebar" class="snap-y"></div>
<div id="mobile-navigation-items" class="snap-x"></div>
`;
desktopNavigationBar =
document.getElementById("desktop-sidebar") as HTMLElement;
mobileNavigationBar =
document.getElementById("mobile-navigation-items") as HTMLElement;
wheneverScrollIsNeededIs(true);
wheneverPlatformIs(Platform.Desktop);
count = 0;
performance.now = jest.fn(() => count);
mockRequestAnimationFrame();
});
test("works on views without animatable elements", () => {
document.body.innerHTML = ``;
expect(() => runAnimation()).not.toThrow();
});
test("does not animate when no scrolling is needed on desktop", async () => {
wheneverScrollIsNeededIs(false);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
});
test("does not animate when no scrolling is needed on mobile", async () => {
wheneverScrollIsNeededIs(false);
wheneverPlatformIs(Platform.Mobile);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
});
test("cancels animation on user interaction on desktop", async () => {
mockRequestAnimationFrame(() => {
fireEvent.mouseDown(desktopNavigationBar);
});
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
});
test("cancels animation on user interaction on mobile", async () => {
wheneverPlatformIs(Platform.Mobile);
mockRequestAnimationFrame(() => {
fireEvent.touchStart(mobileNavigationBar);
});
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
});
test("performs animation on desktop", async () => {
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(animationSteps);
});
test("performs animation on mobile", async () => {
wheneverPlatformIs(Platform.Mobile);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(animationSteps);
});
function wheneverScrollIsNeededIs(isScrollNeeded: boolean) {
const clientLength = 100;
const scrollHeight = isScrollNeeded ? 200 : 50;
Object.defineProperty(
desktopNavigationBar, "clientHeight", {
value: clientLength,
configurable: true
});
Object.defineProperty(
mobileNavigationBar, "clientWidth", {
value: clientLength,
configurable: true
});
Object.defineProperty(
desktopNavigationBar, "scrollHeight", {
value: scrollHeight,
configurable: true
});
Object.defineProperty(
mobileNavigationBar, "scrollWidth", {
value: scrollHeight,
configurable: true
});
}
function wheneverPlatformIs(platform: Platform) {
const isMobile = platform === Platform.Mobile;
Object.defineProperty(window, 'matchMedia', {
value: jest.fn().mockImplementation(() => ({
matches: isMobile
})),
configurable: true
});
}
function mockRequestAnimationFrame(customAction: () => void = () => {}) {
requestAnimationFrameSpy =
jest.spyOn(window, "requestAnimationFrame").
mockImplementation((frameRequestCallback: FrameRequestCallback) => {
customAction();
frameRequestCallback(count += animationStepDuration);
return 0
});
}
async function runAnimation() {
await initializeAnimations();
jest.runAllTimers();
await Promise.resolve();
}
});
In the test/epic_fantasy_forge_web/live directory in your Phoenix project, create a new directory named authentication. Move the file authentication_test.exs from test/epic_fantasy_forge_web/live inside this new directory. Additionally make some modifications to the this file to additionally test that login view is intially shown. Furthermore modify some existing tests to verify the correct view is displayed. Additionally we will also remove the modal tests and extract them into a new file. Finally your authentication_test.exs file should look something like the below:
defmodule EpicFantasyForgeWeb.AuthenticationTest do
@moduledoc false
use EpicFantasyForgeWeb.ConnCase
import Mox
import Phoenix.LiveViewTest
Code.require_file(
"../../../support/authentication/test_utilities.exs",
__DIR__
)
alias EpicFantasyForgeWeb.TestOAuthAtoms
alias EpicFantasyForgeWeb.TestUtilities
setup :verify_on_exit!
@error "Login failed"
@success "Logged in"
@path "/app"
test "shows login view", %{conn: conn} do
{:ok, view, _html} = live(conn, @path, session: nil)
assert has_element?(view, "#login")
refute has_element?(view, "#overview")
end
test "shows error on client error", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_oauth_login_succeeds()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_oauth", %{
provider: TestOAuthAtoms.provider()
})
render_hook(view, "client_error", %{error: @error})
expected_url = TestOAuthAtoms.oauth_url()
expected_code_verifier = TestOAuthAtoms.code_verifier()
assert_push_event(view, "o-auth-redirect", %{
url: ^expected_url,
code_verifier: ^expected_code_verifier
})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "does not show status indicators when code not present", %{conn: conn} do
{:ok, view, _html} = live(conn, @path, session: nil)
assert has_element?(view, "#login")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
refute has_element?(view, "#toast-error")
end
test "shows error when getting Supabase client for code exchange fails", %{
conn: conn
} do
TestUtilities.when_get_client_fails()
code = TestOAuthAtoms.code()
{:error,
{:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, "#{@path}?code=#{code}")
assert error_message == @error
assert redirect_path == @path
end
test "redirects when getting Supabase client for code exchange fails", %{
conn: conn
} do
TestUtilities.when_get_client_fails()
conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
assert redirected_to(conn) == @path
end
test "shows error when code exchange fails", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_code_exchange_fails()
{:error,
{:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
assert error_message == @error
assert redirect_path == @path
end
test "redirects when code exchange fails", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_code_exchange_fails()
conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
assert redirected_to(conn) == @path
end
test "redirects when error URL query parameter present", %{conn: conn} do
{:error,
{:live_redirect, %{to: redirect_path, flash: %{"error" => error_message}}}} =
live(conn, "#{@path}?error=error")
assert error_message == @error
assert redirect_path == @path
end
test "shows success when code exchange is successful", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_code_exchange_succeeds()
{:error,
{:live_redirect, %{to: redirect_path, flash: %{"info" => info_message}}}} =
live(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
assert info_message == @success
assert redirect_path == @path
end
test "redirects when code exchange is successful", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_code_exchange_succeeds()
conn = get(conn, "#{@path}?code=#{TestOAuthAtoms.code()}")
assert redirected_to(conn) == @path
end
end
To accomdate the modal tests that we just removed from authentication_test.exs, in the directory test/epic_fantasy_forge_web/live/authentication in your Phoenix project, create a file named no_account_test.exs and populate it with the below content:
defmodule EpicFantasyForgeWeb.NoAccountTest do
@moduledoc false
use EpicFantasyForgeWeb.ConnCase
import Phoenix.LiveViewTest
@path "/app"
test "shows warning modal when user attempts to continue without account", %{
conn: conn
} do
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "continue_without_account")
assert has_element?(view, "#login")
assert has_element?(view, "#modal-warning")
refute has_element?(view, "#overview")
end
test "dismisses warning modal when user confirms to continue without account",
%{conn: conn} do
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "continue_without_account")
render_click(view, "confirm_without_account")
assert has_element?(view, "#overview")
refute has_element?(view, "#login")
refute has_element?(view, "#modal-warning")
assert_push_event(view, "show-animation", %{})
end
test "dismisses warning modal when user decides to use account", %{conn: conn} do
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "continue_without_account")
render_click(view, "use_account")
assert has_element?(view, "#login")
refute has_element?(view, "#overview")
refute has_element?(view, "#modal-warning")
end
end
In the test/epic_fantasy_forge_web/live directory, move the file named oauth_test.exs into test/epic_fantasy_forge_web/live/authentication. Additionally make some modifications to this file to additionally test that the correct view is shown. Finally your oauth_test.exs file should look something like the below:
defmodule EpicFantasyForgeWeb.OAuthTest do
@moduledoc false
use EpicFantasyForgeWeb.ConnCase
import Mox
import Phoenix.LiveViewTest
Code.require_file(
"../../../support/authentication/test_utilities.exs",
__DIR__
)
alias EpicFantasyForgeWeb.TestOAuthAtoms
alias EpicFantasyForgeWeb.TestUtilities
@path "/app"
setup :verify_on_exit!
test "shows error when provider is invalid", %{conn: conn} do
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_oauth", %{provider: "invalid"})
refute_push_event(view, "o-auth-redirect", %{
url: _,
code_verifier: _
})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "shows error when getting Supabase client fails", %{conn: conn} do
TestUtilities.when_get_client_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_oauth", %{
provider: TestOAuthAtoms.provider()
})
refute_push_event(view, "o-auth-redirect", %{
url: _,
code_verifier: _
})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "shows error when OAuth login initialization fails", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_oauth_login_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_oauth", %{
provider: TestOAuthAtoms.provider()
})
refute_push_event(view, "o-auth-redirect", %{
url: _,
code_verifier: _
})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "redirects when OAuth login initialization succeeds", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_oauth_login_succeeds()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_oauth", %{
provider: TestOAuthAtoms.provider()
})
expected_url = TestOAuthAtoms.oauth_url()
expected_code_verifier = TestOAuthAtoms.code_verifier()
assert_push_event(view, "o-auth-redirect", %{
url: ^expected_url,
code_verifier: ^expected_code_verifier
})
assert has_element?(view, "#login")
assert has_element?(view, "#loading-spinner")
refute has_element?(view, "#overview")
refute has_element?(view, "#toast-info")
refute has_element?(view, "#toast-error")
end
end
In the test/epic_fantasy_forge_web/live directory, move the file named otp_test.exs into test/epic_fantasy_forge_web/live/authentication. Additionally make some modifications to this file to additionally test that the correct view is shown. Finally your otp_test.exs file should look something like the below:
defmodule EpicFantasyForgeWeb.OTPTest do
@moduledoc false
use EpicFantasyForgeWeb.ConnCase
import Mox
import Phoenix.LiveViewTest
Code.require_file(
"../../../support/authentication/test_utilities.exs",
__DIR__
)
alias EpicFantasyForgeWeb.TestUtilities
@code %{
"1" => "1",
"2" => "2",
"3" => "3",
"4" => "4",
"5" => "5",
"6" => "6"
}
@path "/app"
setup :verify_on_exit!
test "shows error when getting Supabase client for login with OTP fails", %{
conn: conn
} do
TestUtilities.when_get_client_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
refute has_element?(view, "#otp-inputs")
end
test "shows error when login with OTP fails", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
refute has_element?(view, "#otp-inputs")
end
test "shows login code input when login with OTP succeeds", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_succeeds()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-info")
assert has_element?(view, "#otp-inputs")
refute has_element?(view, "#overview")
refute has_element?(view, "#toast-error")
refute has_element?(view, "#loading-spinner")
end
test "shows error when getting Supabase client for OTP code verification fails",
%{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_succeeds()
TestUtilities.when_get_client_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
render_click(view, "verify_otp_code", %{code: @code})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
assert has_element?(view, "#otp-inputs")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "shows error when OTP code verification fails", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_succeeds()
TestUtilities.when_get_client_succeeds()
TestUtilities.when_verification_fails()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
render_click(view, "verify_otp_code", %{code: @code})
assert has_element?(view, "#login")
assert has_element?(view, "#toast-error")
assert has_element?(view, "#otp-inputs")
refute has_element?(view, "#overview")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-info")
end
test "shows success banner when OTP code verification succeeds", %{conn: conn} do
TestUtilities.when_get_client_succeeds()
TestUtilities.when_otp_login_succeeds()
TestUtilities.when_get_client_succeeds()
TestUtilities.when_verification_succeeds()
{:ok, view, _html} = live(conn, @path, session: nil)
render_click(view, "login_with_otp", %{email: "user@example.com"})
render_click(view, "verify_otp_code", %{code: @code})
assert has_element?(view, "#overview")
assert has_element?(view, "#toast-info")
refute has_element?(view, "#login")
refute has_element?(view, "#loading-spinner")
refute has_element?(view, "#toast-error")
assert_push_event(view, "show-animation", %{})
end
end
In the test/support directory, move the file named test_utilities.exs into test/support/authentication.
App
In the src/test directory of your Tauri project, create a new file named animation-test-utils.ts and populate it with the below content:
const animationDuration = 1000;
export const animationStepDuration = 100;
export const animationSteps = animationDuration / animationStepDuration * 2 + 1;
export enum Platform {
Desktop,
Mobile,
};
In the src/test directory of your Tauri project, create a new file named animations.test.ts and populate it with the below content:
import {
animationStepDuration,
animationSteps,
Platform } from "./animation-test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/dom";
import { initializeAnimations } from "../ts/animations";
import "@testing-library/jest-dom/vitest";
describe("Animations", () => {
let desktopNavigationBar: HTMLElement;
let mobileNavigationBar: HTMLElement;
let requestAnimationFrameSpy: ReturnType<typeof vi.spyOn>;
let count = -animationStepDuration;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
document.body.innerHTML = `
<div id="desktop-sidebar" class="snap-y"></div>
<div id="mobile-navigation-items" class="snap-x"></div>
`;
desktopNavigationBar =
document.getElementById("desktop-sidebar") as HTMLElement;
mobileNavigationBar =
document.getElementById("mobile-navigation-items") as HTMLElement;
wheneverScrollIsNeededIs(true);
wheneverPlatformIs(Platform.Desktop);
count = 0;
performance.now = vi.fn(() => count);
mockRequestAnimationFrame();
});
it("works on views without animatable elements", () => {
document.body.innerHTML = ``;
expect(() => runAnimation()).not.toThrow();
});
it("does not animate when no scrolling is needed on desktop", async () => {
wheneverScrollIsNeededIs(false);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
});
it("does not animate when no scrolling is needed on mobile", async () => {
wheneverScrollIsNeededIs(false);
wheneverPlatformIs(Platform.Mobile);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
});
it("cancels animation on user interaction on desktop", async () => {
mockRequestAnimationFrame(() => {
fireEvent.mouseDown(desktopNavigationBar);
});
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
});
it("cancels animation on user interaction on mobile", async () => {
wheneverPlatformIs(Platform.Mobile);
mockRequestAnimationFrame(() => {
fireEvent.touchStart(mobileNavigationBar);
});
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
});
it("performs animation on desktop", async () => {
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(animationSteps);
});
it("performs animation on mobile", async () => {
wheneverPlatformIs(Platform.Mobile);
await runAnimation();
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(animationSteps);
});
function wheneverScrollIsNeededIs(isScrollNeeded: boolean) {
const clientLength = 100;
const scrollHeight = isScrollNeeded ? 200 : 50;
Object.defineProperty(
desktopNavigationBar, "clientHeight", {
value: clientLength,
configurable: true
});
Object.defineProperty(
mobileNavigationBar, "clientWidth", {
value: clientLength,
configurable: true
});
Object.defineProperty(
desktopNavigationBar, "scrollHeight", {
value: scrollHeight,
configurable: true
});
Object.defineProperty(
mobileNavigationBar, "scrollWidth", {
value: scrollHeight,
configurable: true
});
}
function wheneverPlatformIs(platform: Platform) {
const isMobile = platform === Platform.Mobile;
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation(() => ({
matches: isMobile
})),
configurable: true
});
}
function mockRequestAnimationFrame(customAction: () => void = () => {}) {
requestAnimationFrameSpy =
vi.spyOn(window, "requestAnimationFrame").
mockImplementation((frameRequestCallback: FrameRequestCallback) => {
customAction();
frameRequestCallback(count += animationStepDuration);
return 0
});
}
async function runAnimation() {
await initializeAnimations();
vi.runAllTimers();
await Promise.resolve();
}
});
Production Code
Web
To display the icons in the menu (e.g. Worlds, Characters, Locations, History, etc.), we will use Unicode emojis instead of images. Unfortunately not every browser and platform combination supports displaying every Unicode emoji by default. Therefore we will use the font Noto Color Emoji to display the Unicode emojis. This ensures that the emojis are displayed in the same way on every platform.
Update the font section in root.html.heex to also download the Noto Color Emoji font. Refer to the Font section on the Navigation Bar page on this guide for more details how to get the link from Google Fonts. It is possible to select multiple fonts in Google Fonts and get a combined link that downloads all of the selected fonts.
<!-- Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&family=Noto+Color+Emoji&display=swap"
rel="stylesheet"
/>
Add an emoji class to the end of the app.css file:
.emoji {
font-family: "Noto Color Emoji", sans-serif;
}
Additionally, update tailwind.config.js to include the Noto Color Emoji font:
fontFamily: {
noto: ['"Noto Color Emoji"', 'sans-serif'],
orbitron: ['"Orbitron"', 'sans-serif']
},
In the assets/ts directory of your Phoenix project, create a new file named animation-utilities.ts and populate it with the below content:
export interface NavigationAnimation {
isMobile: boolean;
navigationItems: HTMLElement;
status: { isCancelled: boolean };
};
export interface AnimationStatus {
step: (now: number) => void;
now: number;
startTime: number;
removedClasses: string[];
position: Position;
};
export interface Position {
initialStart: number;
change: number;
destination: number;
};
export const scrollClassNames = [
"scroll-smooth",
"snap-mandatory",
"snap-x",
"snap-y"
];
const events = ["touchstart", "mousedown", "wheel", "keydown"];
export const animationDuration = 1000;
export const animationPauseDuration = 500;
export const getDestination =
(navigationItems: HTMLElement, isMobile: boolean): number => {
return isMobile
? navigationItems.scrollWidth - navigationItems.clientWidth
: navigationItems.scrollHeight - navigationItems.clientHeight;
}
export const isMobile =
(): boolean => window.matchMedia("(max-width: 1023px)").matches;
export const listenForUserInteraction =
(navigationItems: HTMLElement, cancelAnimation: () => void) => {
events.forEach((event) =>
navigationItems.addEventListener(
event,
cancelAnimation,
{ once: true }
)
);
};
export const stopListening =
(navigationItems: HTMLElement, cancelAnimation: () => void) => {
events.forEach((event) =>
navigationItems.removeEventListener(event, cancelAnimation)
);
};
export const easeInOutSine =
(progress: number) => 0.5 * (1 - Math.cos(Math.PI * progress));
export const restoreSnap =
(navigationItems: HTMLElement, removedClasses: string[]) => {
removedClasses.forEach((className) =>
navigationItems.classList.add(className));
};
export const executeNextStep =
(navigationAnimation: NavigationAnimation, animationStatus: AnimationStatus):
boolean => {
const elapsedTime = animationStatus.now - animationStatus.startTime;
const progress = Math.min(elapsedTime / animationDuration, 1);
if (progress < 1) {
requestAnimationFrame(animationStatus.step);
return false;
} else {
restoreSnap(
navigationAnimation.navigationItems,
animationStatus.removedClasses
);
return true;
}
};
In the assets/ts directory of your Phoenix project, create a new file named animations.ts and populate it with the below content:
import {
animationDuration,
animationPauseDuration,
AnimationStatus,
easeInOutSine,
executeNextStep,
getDestination,
isMobile,
listenForUserInteraction,
NavigationAnimation,
Position,
restoreSnap,
scrollClassNames,
stopListening
} from "./animation-utilities";
export const initializeAnimations = () => {
requestAnimationFrame(frameRequestCallback);
};
const frameRequestCallback = async () => {
const isMobileView = isMobile();
const target = isMobileView ? "mobile-navigation-items" : "desktop-sidebar";
const navigationItems = document.getElementById(target) as HTMLElement | null;
if (!navigationItems) return;
const destination = getDestination(navigationItems, isMobileView);
if (destination <= 0) return;
const status = { isCancelled: false };
const cancelAnimation = () => {
status.isCancelled = true;
};
listenForUserInteraction(navigationItems, cancelAnimation);
const navigationAnimation: NavigationAnimation = {
isMobile: isMobileView,
navigationItems,
status
};
await performAnimation(navigationAnimation, destination);
stopListening(navigationItems, cancelAnimation);
};
const performAnimation =
async (navigationAnimation: NavigationAnimation, destination: number) => {
await animateScroll(navigationAnimation, destination);
if (!navigationAnimation.status.isCancelled) {
await new Promise((resolve) => setTimeout(resolve, animationPauseDuration));
await animateScroll(navigationAnimation, 0);
}
};
const animateScroll = (
navigationAnimation: NavigationAnimation,
destination: number
) =>
new Promise<void>((resolve) => {
const startTime = performance.now();
const removedClasses: string[] = [];
scrollClassNames.forEach((className: string) => {
if (navigationAnimation.navigationItems.classList.contains(className)) {
navigationAnimation.navigationItems.classList.remove(className);
removedClasses.push(className);
}
});
const initialStart =
navigationAnimation.isMobile
? navigationAnimation.navigationItems.scrollLeft
: navigationAnimation.navigationItems.scrollTop;
const change = destination - initialStart;
const step = (now: number) => {
if (navigationAnimation.status.isCancelled) {
restoreSnap(navigationAnimation.navigationItems, removedClasses);
resolve();
return;
}
const position: Position = {
initialStart,
change,
destination
};
const animationStatus: AnimationStatus = {
step,
now,
startTime,
removedClasses,
position
};
const isDone = performNextStep(navigationAnimation, animationStatus);
if (isDone) {
resolve();
}
};
requestAnimationFrame(step);
}
);
const performNextStep =
(navigationAnimation: NavigationAnimation, animationStatus: AnimationStatus):
boolean => {
const elapsedTime = animationStatus.now - animationStatus.startTime;
const progress =
Math.min(elapsedTime / animationDuration, 1);
const eased = easeInOutSine(progress);
const nextPos =
Math.round(
animationStatus.position.initialStart +
animationStatus.position.change *
eased
);
if (navigationAnimation.isMobile) {
navigationAnimation.navigationItems.scrollLeft = nextPos;
} else {
navigationAnimation.navigationItems.scrollTop = nextPos;
}
return executeNextStep(navigationAnimation, animationStatus);
};
Update app.ts to also initialize animations:
import { initializeNavigationBar } from "./navigation-bar";
import { initializeLiveView } from "./live-view";
import { initializeToast } from "./toast";
import { initializeAnimations } from "./animations";
initializeNavigationBar();
initializeLiveView();
initializeToast();
initializeAnimations();
Add an animations hook to live-view.ts and rename the show error hook to show toast hook:
import topbar from "topbar";
import { LiveSocket } from "phoenix_live_view";
import { Socket } from "phoenix";
import type { ViewHookInterface } from "phoenix_live_view";
import { handleOAuthRedirectEvent } from "./o-auth-redirect";
import { initializeCodeInput } from "./code-input";
import { initializeToast } from "./toast";
import { initializeAnimations } from "./animations";
export function initializeLiveView() {
const csrfToken =
document.querySelector("meta[name='csrf-token']")
?.getAttribute("content") ||
"";
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: getLiveViewHooks(),
});
const BLUE = "#3b82f6";
topbar.config({ barColors: { 0: BLUE } });
window.addEventListener("phx:page-loading-start", () => topbar.show(300));
window.addEventListener("phx:page-loading-stop", () => topbar.hide());
liveSocket.connect();
window.liveSocket = liveSocket;
}
function getLiveViewHooks() {
const hooks: Record<string, object> = {};
hooks.Animation = getAnimationHook();
hooks.ClientError = getClientErrorHook();
hooks.OAuthRedirect = getOAuthRedirectHook();
hooks.ShowToast = getShowToastHook();
hooks.VerificationCodeInput = getCodeInputHook();
return hooks;
}
function getAnimationHook() {
return {
mounted(this: ViewHookInterface) {
this.handleEvent("show-animation", () => {
initializeAnimations();
});
}
};
}
function getClientErrorHook() {
return {
mounted(this: ViewHookInterface) {
window.addEventListener("client-error", (event: Event) => {
const customEvent = event as CustomEvent;
const error = customEvent.detail?.error || "Something went wrong";
this.pushEvent("client_error", { error });
});
}
};
}
function getOAuthRedirectHook() {
return {
mounted(this: ViewHookInterface) {
this.handleEvent(
"o-auth-redirect",
handleOAuthRedirectEvent
);
}
};
}
function getShowToastHook() {
return {
mounted(this: ViewHookInterface) {
this.handleEvent("show-toast", () => {
initializeToast();
});
}
};
}
function getCodeInputHook() {
return {
mounted(this: ViewHookInterface) {
initializeCodeInput(this.el);
}
};
}
Update app.html.heex to have div containers for the show toast and animation hooks:
<main class="
min-h-screen
bg-gradient-to-br
from-indigo-950
via-gray-900
to-fuchsia-950">
<div id="app-live-root" phx-hook="OAuthRedirect">
<div id="app-container" phx-hook="ShowToast">
<div id="view-container" phx-hook="Animation">
<EpicFantasyForgeWeb.AppComponents.toast flash={@flash} />
{@inner_content}
</div>
</div>
</div>
</main>
Update authentication.ex to update the view and push the show-animations event on successful code exchange:
case supabase_go_true().exchange_code_for_session(
client,
pkce.code,
pkce.code_verifier
) do
{:ok, session} ->
{:noreply,
socket
|> assign(
session: session,
view: "overview"
)
|> put_flash(:info, @success_message)
|> push_event("show-animation", %{})
|> push_patch(to: @path)}
{:error, _reason} ->
Logger.error("Failed to exchange code for session")
redirect_on_error(socket)
end
Update otp.ex to update the view and push the show-animations event on successful OTP code verification:
case Authentication.supabase_go_true().verify_otp(
authentication_details.client,
authentication_details.details
) do
{:ok, session} ->
socket =
socket
|> assign(
loading: nil,
is_verifying: false,
session: session,
view: "overview"
)
|> put_flash(:info, @verification_success_msg)
|> push_event("show-toast", %{})
|> push_event("show-animation", %{})
{:noreply, socket}
{:error, _reason} ->
socket =
socket
|> assign(loading: nil)
|> put_flash(:error, @verification_error_msg)
|> push_event("show-toast", %{})
Logger.error("Failed to verify OTP code")
{:noreply, socket}
end
Update app_live.ex to by default set the view to login:
@impl true
def mount(_params, session, socket) do
{:ok,
assign(socket,
email: nil,
is_verifying: false,
loading: nil,
modal: Authentication.get_modal(),
otp_code: %{},
session_data: session,
should_show_modal: false,
view: "login"
)}
end
Additionally update the modal negative event handler to update the view and push the show-animation event when the user confirms to continue without an account:
@impl true
def handle_event(@modal_negative_event, _value, socket) do
socket =
socket
|> assign(
should_show_modal: false,
view: "overview"
)
|> Phoenix.LiveView.clear_flash()
|> Phoenix.LiveView.push_event("show-animation", %{})
{:noreply, socket}
end
Update app_live.html.heex to conditionally display either the login or overview view. Previously we had shown only the login view in app_live.html.heex as that was the only view we had so far implemented. However now we will extract the login view into its own file and instead use app_live.html.heex to orchestrate which view is shown. Replace the contents of app_live.html.heex with the below content:
<%= if @should_show_modal do %>
<EpicFantasyForgeWeb.AppComponents.modal modal={@modal} />
<% end %>
<%= if @view == "login" do %>
<EpicFantasyForgeWeb.Views.login
email={@email}
is_verifying={@is_verifying}
loading={@loading}
otp_code={@otp_code}
session_data={@session_data}
/>
<% end %>
<%= if @view == "overview" do %>
<EpicFantasyForgeWeb.Views.overview />
<% end %>
In the lib/epic_fantasy_forge_web directory of your Phoenux project, create a new directory named views. Inside the new directory create a new directory named templates.
Inside this new templates directory, create a new file named login.html.heex and extract the login view from app_live.html.heex into this new file.
Additionally, in the new templates directory, create a new file named overview.html.heex and populate it with the below content:
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
<div id="overview">
<!-- Desktop sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div class="
relative
m-4
flex
grow
flex-col
gap-y-5
overflow-y-auto
no-scrollbar
rounded-3xl
px-2
py-2
border
border-white/10
bg-gray-900/50">
<div class="flex h-16 shrink-0 items-center justify-center mt-4 mb-0">
<img
class="mx-auto h-20 w-auto"
src="/images/logo.png"
alt="Epic Fantasy Forge"
/>
</div>
<div
class="overflow-y-auto no-scrollbar snap-y snap-mandatory scroll-smooth"
id="desktop-sidebar"
>
<nav class="flex flex-1 flex-col">
<ul role="list">
<li>
<ul role="list" class="px-2 space-y-1">
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πͺ
</span>
<span>Worlds</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Stories</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΏ
</span>
<span>History</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Locations</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π§ββοΈ
</span>
<span>Characters</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΊ
</span>
<span>Cultures</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
β¨
</span>
<span>Magic</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΏ
</span>
<span>Flora</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Fauna</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
β
</span>
<span>Weapons</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Vehicles</span>
</button>
</div>
</li>
<li>
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Objects</span>
</button>
</div>
</li>
</ul>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="lg:pl-72 flex flex-col h-screen overflow-hidden">
<div class="
sticky
top-0
z-40
flex
h-16
shrink-0
items-center
gap-x-4
px-4
lg:pl-0">
<div class="flex flex-1 items-center gap-4 self-stretch">
<!-- Search Bar -->
<form class="relative flex flex-1 min-w-0 items-center">
<input
name="search"
placeholder="Search"
aria-label="Search"
class="
w-full
rounded-full
border
bg-gray-900/50
border-white/10
px-4
py-2
pl-10
text-sm
font-medium
text-white
placeholder:text-gray-300
focus:outline-none
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500
focus:ring-offset-0"
/>
<svg
viewBox="0 0 20 20"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="
pointer-events-none
absolute
left-3
size-5
text-gray-300"
>
<path
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</form>
<div class="flex shrink-0 items-center gap-4">
<!-- Notifications Icon -->
<button
type="button"
class="
flex
items-center
justify-center
rounded-full
border
border-white/10
p-2
transition
text-gray-300
hover:bg-indigo-500
hover:text-white"
aria-label="Notifications"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
data-slot="icon"
aria-hidden="true"
class="size-6"
>
<path
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<!-- Settings Icon -->
<button
type="button"
aria-label="Settings"
class="
flex
items-center
justify-center
rounded-full
border
border-white/10
p-2
text-gray-300
transition
hover:bg-indigo-500
hover:text-white"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
data-slot="icon"
aria-hidden="true"
class="size-6"
>
<path
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Bottom Navigation Bar -->
<nav class="fixed bottom-0 inset-x-0 z-50 lg:hidden">
<div class="mx-auto">
<div class="m-4 rounded-2xl border border-white/10 bg-gray-900/50">
<ul
id="mobile-navigation-items"
role="list"
class="
flex
gap-1
p-2
overflow-x-auto
overscroll-x-contain
scroll-smooth
snap-x
snap-mandatory
no-scrollbar"
style="-webkit-overflow-scrolling: touch; touch-action: pan-x;"
>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Worlds"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">πͺ</span>
<span>Worlds</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Stories"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Stories</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Characters"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π§ββοΈ</span>
<span>Characters</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="History"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">πΏ</span>
<span>History</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Locations"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Locations</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Cultures"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">πΊ</span>
<span>Cultures</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Magic"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">β¨</span>
<span>Magic</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Flora"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">πΏ</span>
<span>Flora</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Fauna"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Fauna</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Weapons"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">β</span>
<span>Weapons</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Vehicles"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Vehicles</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Objects"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Objects</span>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Content -->
<div class="flex-1 pt-0 pb-4 pl-0 pr-4 flex flex-col min-h-0 overflow-hidden">
<div class="
flex-1
min-h-0
w-full
rounded-2xl
shadow-sm
p-6
flex
flex-col
overflow-hidden">
<div class="
flex-1
min-h-0
overflow-y-auto
no-scrollbar
pr-2
space-y-4">
</div>
</div>
</div>
</div>
</div>
Furthermore, inside this new templates directory, create a new file named views.ex and populate it with the below content:
defmodule EpicFantasyForgeWeb.Views do
use EpicFantasyForgeWeb, :html
embed_templates "templates/*"
end
App
Add a font section in app.html to download the Noto Color Emoji font. Refer to the Font section on the Navigation Bar page on this guide for more details how to get the link from Google Fonts.
<!-- Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap"
rel="stylesheet"
/>
Add an emoji class to the end of the app's app.css file:
.emoji {
font-family: "Noto Color Emoji", sans-serif;
}
Update modal.ts to set the the view to Overview on the negative event, i.e. when the user decides to continue without an account. Additionally initialize the animations after awaiting any state changes.
We may also use this opportunity to extract the button logic into its own function since there is now more code. Finally, after applying all of these changes the modal.ts should look like the below:
import { initializeAnimations } from "../ts/animations";
import { tick } from "svelte";
import { view, View } from "./state";
import { writable } from "svelte/store";
export const modal = writable<Modal | null>(null);
export interface ModalDescription {
title: string;
text: string;
bullets: string[];
}
export interface ModalButtons {
positive_label: string;
negative_label: string;
positive_event: () => void;
negative_event: () => void;
}
export interface Modal {
description: ModalDescription;
buttons: ModalButtons;
}
export function showNoAccountModal() {
modal.set({
description: {
title: "Risk of data loss",
text:
"Without an account, your world will be stored on your device. If any of the below occur, your world may be lost:",
bullets: [
"Uninstalling the app",
"Erasing your device",
"Losing your device"
]
},
buttons: getButtons()
});
}
function getButtons(): ModalButtons {
return {
positive_label: "Use account",
negative_label: "Continue without account",
positive_event: () => {
modal.set(null);
},
negative_event: async () => {
modal.set(null);
view.set(View.Overview);
await tick();
initializeAnimations();
}
}
}
In your Tauri project's root directory, in the subdirectory src/components, rename loading-spinner.ts to state.ts. This file will now also be responsible for handling the view state, therefore the rename. We will change the loading variable to be an enum instead of a string. Additionally we will add a new variable to store the view state, i.e. what view is currently active. We will add two new enums, one to represent the loading state and the other to represent the view state. Finally your state.ts should look something like the below:
import { writable } from 'svelte/store';
export enum Loading {
Apple = 'Apple',
Azure = 'Azure',
Discord = 'Discord',
GitHub = 'GitHub',
GitLab = 'GitLab',
Google = 'Google',
None = 'None',
OTP = 'OTP'
}
export enum View {
Login = 'Login',
Overview = 'Overview'
}
export const loading = writable<Loading>(Loading.None);
export const view = writable<View>(View.Login);
In +layout.svelte we will extract the listening for success and error events into their own functions. Additionally we will listen for view transition events. Furthermore we will wrap the whole app view in a div to ensure every view has the same background. Finally your +layout.svelte should look something like this:
<script>
import {
listenForErrorEvents,
listenForSuccessEvents,
listenForTransitionEvents
} from "../ts/event";
import Modal from "../components/modal.svelte";
import { onMount } from "svelte";
import Toast from '../components/toast.svelte';
let { children } = $props();
import "../app.css";
onMount(() => {
listenForSuccessEvents();
listenForErrorEvents();
listenForTransitionEvents();
});
</script>
<div
class="
min-h-screen
bg-gradient-to-br
from-indigo-950
via-gray-900
to-fuchsia-950"
>
<Toast />
<Modal />
{@render children()}
</div>
Update +page.svelte to conditionally display either the login or overview view. Previously we had shown only the login view in +page.svelte as that was the only view we had so far implemented. However now we will extract the login view into its own file and instead use +page.svelte to orchestrate which view is shown. Replace the contents of +page.svelte with the below content:
<script lang="ts">
import { view, View } from '../components/state';
import Login from '../views/login.svelte';
import Overview from '../views/overview.svelte';
</script>
{#if $view === View.Login}
<Login />
{/if}
{#if $view === View.Overview}
<Overview />
{/if}
In the src/ts directory of your Tauri project, create a new file named animation-utilities.ts and populate it with the below content:
export interface NavigationAnimation {
isMobile: boolean;
navigationItems: HTMLElement;
status: { isCancelled: boolean };
};
export interface AnimationStatus {
step: (now: number) => void;
now: number;
startTime: number;
removedClasses: string[];
position: Position;
};
export interface Position {
initialStart: number;
change: number;
destination: number;
};
export const scrollClassNames = [
"scroll-smooth",
"snap-mandatory",
"snap-x",
"snap-y"
];
const events = ["touchstart", "mousedown", "wheel", "keydown"];
export const animationDuration = 1000;
export const animationPauseDuration = 500;
export const getDestination =
(navigationItems: HTMLElement, isMobile: boolean): number => {
return isMobile
? navigationItems.scrollWidth - navigationItems.clientWidth
: navigationItems.scrollHeight - navigationItems.clientHeight;
}
export const isMobile =
(): boolean => window.matchMedia("(max-width: 1023px)").matches;
export const listenForUserInteraction =
(navigationItems: HTMLElement, cancelAnimation: () => void) => {
events.forEach((event) =>
navigationItems.addEventListener(
event,
cancelAnimation,
{ once: true }
)
);
};
export const stopListening =
(navigationItems: HTMLElement, cancelAnimation: () => void) => {
events.forEach((event) =>
navigationItems.removeEventListener(event, cancelAnimation)
);
};
export const easeInOutSine =
(progress: number) => 0.5 * (1 - Math.cos(Math.PI * progress));
export const restoreSnap =
(navigationItems: HTMLElement, removedClasses: string[]) => {
removedClasses.forEach((className) =>
navigationItems.classList.add(className));
};
export const executeNextStep =
(navigationAnimation: NavigationAnimation, animationStatus: AnimationStatus):
boolean => {
const elapsedTime = animationStatus.now - animationStatus.startTime;
const progress = Math.min(elapsedTime / animationDuration, 1);
if (progress < 1) {
requestAnimationFrame(animationStatus.step);
return false;
} else {
restoreSnap(
navigationAnimation.navigationItems,
animationStatus.removedClasses
);
return true;
}
};
In the src/ts directory of your Tauri project, create a new file named animations.ts and populate it with the below content:
import {
animationDuration,
animationPauseDuration,
type AnimationStatus,
easeInOutSine,
executeNextStep,
getDestination,
isMobile,
listenForUserInteraction,
type NavigationAnimation,
type Position,
restoreSnap,
scrollClassNames,
stopListening
} from "./animation-utilities";
export const initializeAnimations = () => {
requestAnimationFrame(frameRequestCallback);
};
const frameRequestCallback = async () => {
const isMobileView = isMobile();
const target = isMobileView ? "mobile-navigation-items" : "desktop-sidebar";
const navigationItems = document.getElementById(target) as HTMLElement | null;
if (!navigationItems) return;
const destination = getDestination(navigationItems, isMobileView);
if (destination <= 0) return;
const status = { isCancelled: false };
const cancelAnimation = () => {
status.isCancelled = true;
};
listenForUserInteraction(navigationItems, cancelAnimation);
const navigationAnimation: NavigationAnimation = {
isMobile: isMobileView,
navigationItems,
status
};
await performAnimation(navigationAnimation, destination);
stopListening(navigationItems, cancelAnimation);
};
const performAnimation =
async (navigationAnimation: NavigationAnimation, destination: number) => {
await animateScroll(navigationAnimation, destination);
if (!navigationAnimation.status.isCancelled) {
await new Promise((resolve) => setTimeout(resolve, animationPauseDuration));
await animateScroll(navigationAnimation, 0);
}
};
const animateScroll = (
navigationAnimation: NavigationAnimation,
destination: number
) =>
new Promise<void>((resolve) => {
const startTime = performance.now();
const removedClasses: string[] = [];
scrollClassNames.forEach((className: string) => {
if (navigationAnimation.navigationItems.classList.contains(className)) {
navigationAnimation.navigationItems.classList.remove(className);
removedClasses.push(className);
}
});
const initialStart =
navigationAnimation.isMobile
? navigationAnimation.navigationItems.scrollLeft
: navigationAnimation.navigationItems.scrollTop;
const change = destination - initialStart;
const step = (now: number) => {
if (navigationAnimation.status.isCancelled) {
restoreSnap(navigationAnimation.navigationItems, removedClasses);
resolve();
return;
}
const position: Position = {
initialStart,
change,
destination
};
const animationStatus: AnimationStatus = {
step,
now,
startTime,
removedClasses,
position
};
const isDone = performNextStep(navigationAnimation, animationStatus);
if (isDone) {
resolve();
}
};
requestAnimationFrame(step);
}
);
const performNextStep =
(navigationAnimation: NavigationAnimation, animationStatus: AnimationStatus):
boolean => {
const elapsedTime = animationStatus.now - animationStatus.startTime;
const progress =
Math.min(elapsedTime / animationDuration, 1);
const eased = easeInOutSine(progress);
const nextPos =
Math.round(
animationStatus.position.initialStart +
animationStatus.position.change *
eased
);
if (navigationAnimation.isMobile) {
navigationAnimation.navigationItems.scrollLeft = nextPos;
} else {
navigationAnimation.navigationItems.scrollTop = nextPos;
}
return executeNextStep(navigationAnimation, animationStatus);
};
In the src/ts directory of you Tauri project, update the file authentication.ts to use the Loading enum instead of strings.
In the src/ts directory of your Tauri project, create a new file named event.ts and populate it with the below content:
import { initializeAnimations } from "../ts/animations";
import { initializeToast, toastInfo, toastError } from "../components/toast";
import { is_verifying } from "../ts/otp";
import { listen } from "@tauri-apps/api/event";
import { Loading, loading, view, View } from "../components/state";
import { tick } from "svelte";
export enum Event {
Success = "Success",
Error = "Error",
Transition = "Transition"
}
export function listenForSuccessEvents() {
listen(Event.Success, async (event: { payload: string }) => {
toastError.set(null);
toastInfo.set(event.payload || "Success");
loading.set(Loading.None);
if (event.payload === "Code sent") {
is_verifying.set(true);
} else {
is_verifying.set(false);
}
await tick();
initializeToast();
});
}
export function listenForErrorEvents() {
listen(Event.Error, async (event: { payload: string }) => {
toastInfo.set(null);
toastError.set(event.payload || "Error");
loading.set(Loading.None);
await tick();
initializeToast();
});
}
export function listenForTransitionEvents() {
listen(Event.Transition, async (event: { payload: View }) => {
view.set(event.payload || View.Login);
await tick();
initializeAnimations();
});
}
In the src/views directory of your Tauri project, create a new file named login.svelte and extract the login view from +page.svelte into this new file.
In the src/views directory of your Tauri project, create a new file named overview.svelte and populate it with the below content:
<div id="overview">
<!-- Desktop sidebar -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<div class="
relative
m-4
flex
grow
flex-col
gap-y-5
overflow-y-auto
no-scrollbar
rounded-3xl
px-2
py-2
border
border-white/10
bg-gray-900/50">
<div class="flex h-16 shrink-0 items-center justify-center mt-4 mb-0">
<img
class="mx-auto h-20 w-auto"
src="/logo.png"
alt="Epic Fantasy Forge"
/>
</div>
<div
class="overflow-y-auto no-scrollbar snap-y snap-mandatory scroll-smooth cursor-grab active:cursor-grabbing select-none"
id="desktop-sidebar"
style="touch-action: pan-y;"
>
<nav class="flex flex-1 flex-col">
<ul role="list">
<li>
<ul role="list" class="px-2 space-y-1">
<li class="snap-start shrink-0 basis-1/2 sm:basis-1/2">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πͺ
</span>
<span>Worlds</span>
</button>
</div>
</li>
<li class="snap-start shrink-0 basis-1/2 sm:basis-1/4">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Stories</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΏ
</span>
<span>History</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Locations</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π§ββοΈ
</span>
<span>Characters</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΊ
</span>
<span>Cultures</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
β¨
</span>
<span>Magic</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
πΏ
</span>
<span>Flora</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Fauna</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
β
</span>
<span>Weapons</span>
</button>
</div>
</li>
<li class="snap-start">
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Vehicles</span>
</button>
</div>
</li>
<li>
<div>
<button class="
group
flex
w-full
items-center
gap-x-4
rounded-[14px]
p-2
text-sm
font-semibold
text-white
hover:bg-indigo-900
cursor-pointer">
<span
aria-hidden="true"
class="
flex
size-9
shrink-0
items-center
justify-center
text-3xl
leading-none
text-white
drop-shadow-md
emoji"
>
π
</span>
<span>Objects</span>
</button>
</div>
</li>
</ul>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="lg:pl-72 flex flex-col h-screen overflow-hidden">
<div class="
sticky
top-0
z-40
flex
h-16
shrink-0
items-center
gap-x-4
px-4
lg:pl-0">
<div class="flex flex-1 items-center gap-4 self-stretch">
<!-- Search Bar -->
<form class="relative flex flex-1 min-w-0 items-center">
<input
name="search"
placeholder="Search"
aria-label="Search"
class="
w-full
rounded-full
border
bg-gray-900/50
border-white/10
px-4
py-2
pl-10
text-sm
font-medium
text-white
placeholder:text-gray-300
focus:outline-none
focus:ring-2
focus:ring-indigo-500
focus:border-indigo-500
focus:ring-offset-0"
/>
<svg
viewBox="0 0 20 20"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="
pointer-events-none
absolute
left-3
size-5
text-gray-300"
>
<path
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</form>
<div class="flex shrink-0 items-center gap-4">
<!-- Notifications Icon -->
<button
type="button"
class="
flex
items-center
justify-center
rounded-full
border
border-white/10
p-2
transition
text-gray-300
hover:bg-indigo-500
hover:text-white
cursor-pointer"
aria-label="Notifications"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
data-slot="icon"
aria-hidden="true"
class="size-6"
>
<path
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<!-- Settings Icon -->
<button
type="button"
aria-label="Settings"
class="
flex
items-center
justify-center
rounded-full
border
border-white/10
p-2
text-gray-300
transition
hover:bg-indigo-500
hover:text-white
cursor-pointer"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
data-slot="icon"
aria-hidden="true"
class="size-6"
>
<path
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Bottom Navigation Bar -->
<nav class="fixed bottom-0 inset-x-0 z-50 lg:hidden">
<div class="mx-auto">
<div class="m-4 rounded-2xl border border-white/10 bg-gray-900/50">
<ul
id="mobile-navigation-items"
role="list"
class="
flex
gap-1
p-2
overflow-x-auto
overflow-y-hidden
overscroll-x-contain
scroll-smooth
snap-x
snap-mandatory
no-scrollbar"
style="-webkit-overflow-scrolling: touch; touch-action: pan-x;"
>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Worlds"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">πͺ</span>
<span>Worlds</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Stories"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Stories</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Characters"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π§ββοΈ</span>
<span>Characters</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="History"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">πΏ</span>
<span>History</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Locations"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Locations</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Cultures"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">πΊ</span>
<span>Cultures</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Magic"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">β¨</span>
<span>Magic</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Flora"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">πΏ</span>
<span>Flora</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Fauna"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Fauna</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Weapons"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">β</span>
<span>Weapons</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Vehicles"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Vehicles</span>
</button>
</li>
<li class="snap-start shrink-0 basis-1/4 sm:basis-1/6">
<button
aria-label="Objects"
class="
w-full
flex
flex-col
items-center
justify-center
gap-2
rounded-xl
px-2
py-2
text-xs
font-medium
text-gray-200
hover:bg-indigo-900
cursor-pointer"
>
<span class="text-3xl leading-none emoji">π</span>
<span>Objects</span>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Content -->
<div class="flex-1 pt-0 pb-4 pl-0 pr-4 flex flex-col min-h-0 overflow-hidden">
<div class="
flex-1
min-h-0
w-full
rounded-2xl
shadow-sm
p-6
flex
flex-col
overflow-hidden">
<div class="
flex-1
min-h-0
overflow-y-auto
no-scrollbar
pr-2
space-y-4">
</div>
</div>
</div>
</div>
</div>
To keep the UI looking elegant, the scrollbars will be hidden. On some web browsers the scrollbar style may not match your web app's style and look out of place. Scrolling will still be possible, we will just not visually show a scrollbar. To achieve this behavior, in the src directory of your Tauri project, add the below block to the end of app.css:
``` css file="app.css" .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
.no-scrollbar::-webkit-scrollbar { display: none; width: 0; height: 0; }
html, body { -ms-overflow-style: none; scrollbar-width: none; }
html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; width: 0; height: 0; }
Update [deep_link.rs]{:target="_blank"} to also emit a view transition event when code exchange succeeds:
``` rust title="deep_link.rs"
match on_open_url(&dependencies, url.as_ref()) {
Ok(_) => {
app.emit("Success", "Logged in").unwrap();
app.emit("Transition", "Overview").unwrap();
},
Err(_error) => {
app.emit("Error", "Login failed").unwrap();
}
}
Update oauth.rs to use capitalized strings for the OAuth providers.
Update otp.rs to also emit a view transition event when OTP code verification succeeds:
match verify_otp_code(&dependencies, code) {
Ok(_) => {
app.emit("Success", "Logged in").unwrap();
app.emit("Transition", "Overview").unwrap();
},
Err(_error) => app.emit("Error", "Code verification failed").unwrap()
}



