Add frontend documentation
Diff
chartered-frontend/src/components/Icon.svelte | 23 +++++++++++++++++++++++
chartered-frontend/src/components/NavItem.svelte | 5 +++++
chartered-frontend/src/components/RelativeTime.svelte | 4 ++++
chartered-frontend/src/routes/(authed)/+layout.svelte | 3 +++
chartered-frontend/src/routes/(unauthed)/+layout.svelte | 3 +++
chartered-frontend/src/routes/(authed)/organisations/+page.svelte | 1 +
chartered-frontend/src/routes/(authed)/search/+page.svelte | 2 ++
chartered-frontend/src/routes/(authed)/crates/[organisation]/+page.svelte | 19 +++++++++++++++++++
chartered-frontend/src/routes/(authed)/crates/[organisation]/AddMember.svelte | 32 ++++++++++++++++++++++++++++++++
chartered-frontend/src/routes/(authed)/crates/[organisation]/Member.svelte | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-frontend/src/routes/(authed)/organisations/list/+page.svelte | 1 +
chartered-frontend/src/routes/(authed)/sessions/list/+page.svelte | 19 ++++++++++++++-----
chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte | 2 ++
chartered-frontend/src/routes/(unauthed)/login/oauth/+page.svelte | 2 ++
chartered-frontend/src/routes/(authed)/crates/[organisation]/[crate]/+page.svelte | 9 +++++++++
chartered-frontend/src/routes/(authed)/crates/[organisation]/[crate]/Dependency.svelte | 15 +++++++++++++--
chartered-frontend/src/routes/(authed)/crates/[organisation]/[crate]/MemberTab.svelte | 9 +++++++++
chartered-frontend/src/routes/(authed)/crates/[organisation]/[crate]/VersionTab.svelte | 15 +++++++++++++--
18 files changed, 203 insertions(+), 13 deletions(-)
@@ -1,12 +1,35 @@
<script type="typescript">
import feather from 'feather-icons';
export const directions = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
/**
* Name of the icon, can be found on https://feathericons.com/
*/
export let name: string;
/**
* Compass rotation of the icon
*/
export let direction = 'n';
/**
* Thickness of the icon
*/
export let strokeWidth: string | null = null;
/**
* Colour of the icon
*/
export let stroke: string | null = null;
/**
* Width of the icon
*/
export let width = '1em';
/**
* Height of the icon
*/
export let height = '1em';
$: icon = feather.icons[name];
@@ -12,7 +12,12 @@
*/
export let aliases: string[] = [];
/**
* Contains whether this link is currently considered active based on the current URL.
*/
let active: boolean;
// whenever the page name updates, check if the link (or its aliases) match up and set as active
$: if (href === '/') {
active = $page.url.pathname === href;
} else {
@@ -1,6 +1,10 @@
<script type="typescript">
import { DateTime } from 'luxon';
/**
* A time string to convert to relative time, can be anything that can be read by
* `Date.parse`.
*/
export let time: string;
const date = DateTime.fromMillis(Date.parse(time));
@@ -1,9 +1,12 @@
<script type="typescript">
import { auth } from '../../stores/auth';
import { goto } from '$app/navigation';
import Nav from '../../components/Nav.svelte';
import NavItem from '../../components/NavItem.svelte';
// watch the `$auth` store for changes to authentication, if their `$auth` disappears
// (such as from expiry), redirect to the login page. this also covers the case where
// the user requests `/`, we'll redirect straight to login from this too.
$: if (!$auth) {
goto('/auth/login', { replaceState: true });
}
@@ -1,7 +1,10 @@
<script type="typescript">
import { auth } from '../../stores/auth';
import { goto } from '$app/navigation';
// watch the `$auth` store, if the user suddenly becomes authenticated (such as after login)
// then redirect to the homepage. this also covers the case where the user navigates directly
// to login despite already having a session
$: if ($auth) {
goto('/', { replaceState: true });
}
@@ -1,5 +1,6 @@
<script type="typescript">
import { goto } from '$app/navigation';
// redirect to list view
goto('/organisations/list');
</script>
@@ -1,9 +1,11 @@
<script type="typescript">
import { page } from '$app/stores';
import { request } from '../../../stores/auth';
import Spinner from '../../../components/Spinner.svelte';
import type { Search } from '../../../types/crate';
// whenever the `q` query parameter changes, send a request to the backend with that
// search term.
let searchPromise: Promise<Search>;
$: searchPromise = request(`/web/v1/crates/search?q=${$page.url.searchParams.get('q')}`);
</script>
@@ -9,9 +9,17 @@
import AddMember from './AddMember.svelte';
import type { CrateMembers, CrateMember } from '../../../../types/crate';
// Load the requested organisation from the URL
let organisationPromise: Promise<OrganisationDetail & CrateMembers>;
$: organisationPromise = request(`/web/v1/organisations/${$page.params.organisation}`);
/**
* Whenever a member is updated/added/deleted to this organisation, we'll want to reload to ensure we
* show the user exactly what the server currently sees.
*
* @param event a struct containing the updated member's UUID, so we can empty the newMember value if that member
* has just been added to we don't show them twice.
*/
function reload(event: { detail: string }) {
organisationPromise = request(`/web/v1/organisations/${$page.params.organisation}`);
@@ -20,11 +28,18 @@
}
}
/**
* Contains all the possible tabs, used for maintaining state on the current tab.
*/
enum Tab {
CRATES,
MEMBERS,
}
/**
* Mapping of `Tab`s to their human-readable form alongside a friendly icon to show to the
* user.
*/
const allTabs = [
{
id: Tab.CRATES,
@@ -38,7 +53,11 @@
},
];
// binding to the current tab the user has selected
let currentTab = Tab.CRATES;
// contains the member the user is currently considering adding to the org & has not yet persisted to
// the server.
let newMember: CrateMember | null = null;
</script>
@@ -6,20 +6,46 @@
import { createEventDispatcher } from 'svelte';
import type { UserSearch, UserSearchUser } from '../../../../types/user';
// Create the dispatcher to send an event whenever a new member is selected
// by the user.
const dispatch = createEventDispatcher();
/**
* A list of UUIDs to hide from the search, so we can skip showing users that
* are already members.
*/
export let hideUuids: string[] = [];
/**
* Contains whether the search results are currently loading so a spinner
* can be shown.
*/
let loading = false;
/**
* Binding to the search terms the user has entered.
*/
let search = '';
/**
* A list of search results from the backend
*/
let searchResults: UserSearchUser[] = [];
// update `searchResults` whenever `search` is updated
$: performSearch(search);
// debounce the user's input for 250ms, so we don't just spam the backend with search
// requests even though the user isn't finished yet.
const onInput = debounce((event) => {
search = event.target.value;
}, 250);
/**
* Call the backend and fetch user results for the user's given search terms.
*
* @param search terms to search for
*/
async function performSearch(search: string) {
if (search === '') {
return;
@@ -29,7 +55,6 @@
try {
let result = await request<UserSearch>(`/web/v1/users/search?q=${search}`);
searchResults = result.users || [];
} catch (e: unknown) {
console.log(e);
@@ -38,6 +63,11 @@
}
}
/**
* Send an event back to the parent component whenever a user is selected.
*
* @param member member to send to the parent component
*/
function dispatchNewMember(member: UserSearchUser) {
dispatch('new', member);
searchResults = [];
@@ -10,32 +10,76 @@
const dispatch = createEventDispatcher();
let clazz = '';
/**
* The name of the organisation that this is a `member` of.
*/
export let organisation: string;
/**
* The name of the crate that this is a `member` of, or `null` if we're showing organisation
* results.
*/
export let crate: string | null = null;
/**
* The member to show
*/
export let member: CrateMember;
/**
* A list of new permissions for the user, this is normally set by the user in the UI via bindings and default
* to the user's current permissions. Whenever this differs from the user's current permissions then the save
* icon is shown. This is exposed to the consumer for new ("prospective") members that don't currently exist
* on the backend, where the consumer wants to give a default `VISIBLE` permission but also show the save icon.
*/
export let newPermissions = member.permissions;
/**
* A list of possible permissions this user can be given.
*/
export let possiblePermissions: string[];
/**
* A list of CSS classes to add to the outer div.
*/
let clazz = '';
export { clazz as class };
/**
* Whether the member is currently being persisted to the backend and a spinner is showing.
*/
let saving = false;
/**
* Any errors that happened upon the last invocation of `save` to give feedback to the user.
*/
let error: string | null = null;
/**
* Persist updates to this member to the backend.
*/
async function save() {
saving = true;
error = null;
try {
// determine the HTTP verb to send for this membership change.
let method;
if (!newPermissions.includes('VISIBLE')) {
// if the user is removing the VISIBLE permission from this member then it's a DELETE
// operation otherwise their membership would be useless.
method = 'DELETE';
} else if (member.permissions.length === 0) {
// if the member did not have initial permissions on this crate/org then they're a new
// member to it, welcome aboard!
method = 'PUT';
} else {
// anything else is simply just an update to an existing member
method = 'PATCH';
}
// this component is called from both organisation views and crate views, so we need to figure
// out which one we need to persist the changes to...
let url;
if (crate) {
url = `web/v1/crates/${organisation}/${crate}`;
@@ -43,6 +87,7 @@
url = `web/v1/organisations/${organisation}`;
}
// send the membership update to the backend
let result = await fetch(`${BASE_URL}/a/${$auth?.auth_key}/${url}/members`, {
method,
headers: {
@@ -61,6 +106,9 @@
throw new Error(json.error);
}
// fast-update the permissions locally to hide the save button, then prompt the parent
// component to update their membership list so the user gets the most up-to-date view
// of permissions that the server sees.
member.permissions = newPermissions;
dispatch('updated', member.uuid);
} catch (e) {
@@ -1,9 +1,10 @@
<script type="typescript">
import Spinner from '../../../../components/Spinner.svelte';
import { request } from '../../../../stores/auth';
import type { OrganisationList } from '../../../../types/organisations';
import ErrorAlert from '../../../../components/ErrorAlert.svelte';
// fetch a list of all the current user's organisations from the backend
const organisationsPromise = request<OrganisationList>('/web/v1/organisations');
</script>
@@ -6,16 +6,23 @@
import Icon from '../../../../components/Icon.svelte';
import DeleteSessionModal from './DeleteSessionModal.svelte';
let sessionPromise = loadSessions();
/**
* If not null, then the user is currently attempting to delete a session and a modal is being
* shown for them to confirm.
*/
let deleting: Session | null = null;
function loadSessions(): Promise<Sessions> {
return request<Sessions>('/web/v1/sessions');
}
/**
* Grab the list of current sessions from the user from the backend.
*/
let sessionPromise: Promise<Sessions> = request<Sessions>('/web/v1/sessions');
/**
* Reload all the user's current sessions whenever a session is deleted so the user gets
* an up-to-date view of what the backend sees.
*/
function reloadSessions() {
sessionPromise = loadSessions();
sessionPromise = request<Sessions>('/web/v1/sessions');
}
</script>
@@ -5,6 +5,8 @@
import Icon from '../../../../components/Icon.svelte';
import type { User } from '../../../../types/user';
// grab the requested user from the backend and determine a `displayName` for them to show on their
// profile.
let userPromise: Promise<User & { displayName?: string }>;
$: userPromise = request<User>(`/web/v1/users/info/${$page.params.uuid}`).then(
(user: User & { displayName?: string }) => {
@@ -1,9 +1,11 @@
<script type="typescript">
import { page } from '$app/stores';
import { handleOAuthCallback } from '../../../../stores/auth';
import Spinner from '../../../../components/Spinner.svelte';
import ErrorAlert from '../../../../components/ErrorAlert.svelte';
// pass the payload onto the backend to verify and create a session, we'll just show a
// spinner in the meantime.
const callback = handleOAuthCallback($page.url.search);
</script>
@@ -9,12 +9,19 @@
import VersionTab from './VersionTab.svelte';
import MemberTab from './MemberTab.svelte';
/**
* Contains all the possible tabs, used for maintaining state on the current tab.
*/
enum Tab {
README,
VERSIONS,
MEMBERS,
}
/**
* Mapping of `Tab`s to their human-readable form alongside a friendly icon to show to the
* user.
*/
const allTabs = [
{
id: Tab.README,
@@ -33,9 +40,11 @@
},
];
// lookup the crate currently requested by the user based on the URL
let cratePromise: Promise<Crate>;
$: cratePromise = request(`/web/v1/crates/${$page.params.organisation}/${$page.params.crate}`);
// binding to the current tab the user has selected
let currentTab = Tab.README;
</script>
@@ -1,11 +1,22 @@
<script type="typescript">
import type { VersionDependency } from '../../../../../types/crate';
let clazz = '';
/**
* The dependency to show
*/
export let dependency: VersionDependency;
/**
* CSS classes to apply to the outer div drawn by this component.
*/
let clazz = '';
export { clazz as class };
/**
* Extracts the "organisation" part of an SSH url if one exists, otherwise returns the
* full URL. This currently assumes that all dependencies with an SSH URI are dependencies
* local to this instance of chartered, but that's not always the case.
*/
function getLocalDependencyOrganisation(): string | undefined {
const s = dependency.registry?.split('/');
@@ -5,11 +5,20 @@
import Member from '../Member.svelte';
import type { CrateMembers, CrateMember } from '../../../../../types/crate';
/**
* Binding to the latest user selected after a search, this is a "prospective member" that is not yet
* persisted to the backend until the user presses the save button.
*/
let newMember: CrateMember | null = null;
// Grab all the current crate's members
let membersPromise: Promise<CrateMembers>;
$: membersPromise = request(`/web/v1/crates/${$page.params.organisation}/${$page.params.crate}/members`);
/**
* When a member is updated/added/deleted to this crate, we'll want to reload to show the user exactly
* what the server sees the current state is.
*/
function reloadMembers() {
newMember = null;
membersPromise = request(`/web/v1/crates/${$page.params.organisation}/${$page.params.crate}/members`);
@@ -1,13 +1,24 @@
<script type="typescript">
import type { Version } from '../../../../../types/crate';
import Icon from '../../../../../components/Icon.svelte';
import RelativeTime from '../../../../../components/RelativeTime.svelte';
let clazz = '';
/**
* The crate version to draw.
*/
export let version: Version;
/**
* CSS classes to apply to the outer element.
*/
let clazz = '';
export { clazz as class };
/**
* Converts a number of bytes to a friendly human-readable form.
*
* @param size number of bytes
*/
function humanFileSize(size: number): string {
const i = Math.floor(Math.log(size) / Math.log(1024));
return Number((size / Math.pow(1024, i)).toFixed(2)) + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];