Full CRUD for Lists

This commit is contained in:
Lim Chee Aun 2023-04-05 23:30:26 +08:00
parent 4b42118742
commit ff1a9fa444
8 changed files with 621 additions and 34 deletions

View file

@ -39,7 +39,7 @@ registerRoute(imageRoute);
// - /api/v1/preferences
// - /api/v1/lists/:id
const apiExtendedRoute = new RegExpRoute(
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)/,
/^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)$/,
new StaleWhileRevalidate({
cacheName: 'api-extended',
plugins: [

View file

@ -260,6 +260,28 @@
animation: shine 1s ease-in-out 1s;
}
#list-add-remove-container .list-add-remove {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 8px 0;
list-style: none;
}
#list-add-remove-container .list-add-remove button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
#list-add-remove-container .list-add-remove button .icon {
opacity: 0.15;
}
#list-add-remove-container .list-add-remove button.checked .icon {
opacity: 1;
color: var(--green-color);
}
@media (min-width: 40em) {
.timeline-start .account-container {
--item-radius: 16px;

View file

@ -1,13 +1,7 @@
import './account-info.css';
import {
Menu,
MenuDivider,
MenuHeader,
MenuItem,
SubMenu,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
@ -24,6 +18,8 @@ import AccountBlock from './account-block';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import ListAddEdit from './list-add-edit';
import Loader from './loader';
import Modal from './modal';
import TranslationBlock from './translation-block';
@ -487,6 +483,7 @@ function RelatedActions({ info, instance, authenticated }) {
const menuInstanceRef = useRef(null);
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
return (
<>
@ -583,6 +580,17 @@ function RelatedActions({ info, instance, authenticated }) {
<Icon icon="translate" />
<span>Translate bio</span>
</MenuItem>
{/* Add/remove from lists is only possible if following the account */}
{following && (
<MenuItem
onClick={() => {
setShowAddRemoveLists(true);
}}
>
<Icon icon="list" />
<span>Add/remove from Lists</span>
</MenuItem>
)}
<MenuDivider />
</>
)}
@ -840,6 +848,18 @@ function RelatedActions({ info, instance, authenticated }) {
<TranslatedBioSheet note={note} fields={fields} />
</Modal>
)}
{!!showAddRemoveLists && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowAddRemoveLists(false);
}
}}
>
<AddRemoveListsSheet accountID={accountID.current} />
</Modal>
)}
</>
);
}
@ -900,4 +920,127 @@ function TranslatedBioSheet({ note, fields }) {
</div>
);
}
function AddRemoveListsSheet({ accountID }) {
const { masto } = api();
const [uiState, setUiState] = useState('default');
const [lists, setLists] = useState([]);
const [listsContainingAccount, setListsContainingAccount] = useState([]);
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
useEffect(() => {
setUiState('loading');
(async () => {
try {
const lists = await masto.v1.lists.list();
const listsContainingAccount = await masto.v1.accounts.listLists(
accountID,
);
console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount);
setUiState('default');
} catch (e) {
console.error(e);
setUiState('error');
}
})();
}, [reloadCount]);
const [showListAddEditModal, setShowListAddEditModal] = useState(false);
return (
<div class="sheet" id="list-add-remove-container">
<header>
<h2>Add/Remove from Lists</h2>
</header>
<main>
{lists.length > 0 ? (
<ul class="list-add-remove">
{lists.map((list) => {
const inList = listsContainingAccount.some(
(l) => l.id === list.id,
);
return (
<li>
<button
type="button"
class={`light ${inList ? 'checked' : ''}`}
disabled={uiState === 'loading'}
onClick={() => {
setUiState('loading');
(async () => {
try {
if (inList) {
await masto.v1.lists.removeAccount(list.id, {
accountIds: [accountID],
});
} else {
await masto.v1.lists.addAccount(list.id, {
accountIds: [accountID],
});
}
// setUiState('default');
reload();
} catch (e) {
console.error(e);
setUiState('error');
alert(
inList
? 'Unable to remove from list.'
: 'Unable to add to list.',
);
}
})();
}}
>
<Icon icon="check-circle" />
<span>{list.title}</span>
</button>
</li>
);
})}
</ul>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load lists.</p>
) : (
<p class="ui-state">No lists.</p>
)}
<button
type="button"
class="plain2"
onClick={() => setShowListAddEditModal(true)}
disabled={uiState !== 'default'}
>
<Icon icon="plus" size="l" /> <span>New list</span>
</button>
</main>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}
export default AccountInfo;

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api';
function ListAddEdit({ list, onClose = () => {} }) {
const { masto } = api();
const [uiState, setUiState] = useState('default');
const editMode = !!list;
const nameFieldRef = useRef();
const repliesPolicyFieldRef = useRef();
useEffect(() => {
if (editMode) {
nameFieldRef.current.value = list.title;
repliesPolicyFieldRef.current.value = list.repliesPolicy;
}
}, [editMode]);
return (
<div class="sheet">
<header>
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
</header>
<main>
<form
class="list-form"
onSubmit={(e) => {
e.preventDefault(); // Get form values
const formData = new FormData(e.target);
const title = formData.get('title');
const repliesPolicy = formData.get('replies_policy');
console.log({
title,
repliesPolicy,
});
setUiState('loading');
(async () => {
try {
let listResult;
if (editMode) {
listResult = await masto.v1.lists.update(list.id, {
title,
replies_policy: repliesPolicy,
});
} else {
listResult = await masto.v1.lists.create({
title,
replies_policy: repliesPolicy,
});
}
console.log(listResult);
setUiState('default');
onClose({
state: 'success',
list: listResult,
});
} catch (e) {
console.error(e);
setUiState('error');
alert(
editMode ? 'Unable to edit list.' : 'Unable to create list.',
);
}
})();
}}
>
<div class="list-form-row">
<label for="list-title">
Name{' '}
<input
ref={nameFieldRef}
type="text"
id="list-title"
name="title"
required
disabled={uiState === 'loading'}
/>
</label>
</div>
<div class="list-form-row">
<select
ref={repliesPolicyFieldRef}
name="replies_policy"
required
disabled={uiState === 'loading'}
>
<option value="list">Show replies to list members</option>
<option value="followed">Show replies to people I follow</option>
<option value="none">Don't show replies</option>
</select>
</div>
<div class="list-form-footer">
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
</button>
{editMode && (
<button
type="button"
class="light danger"
disabled={uiState === 'loading'}
onClick={() => {
const yes = confirm('Delete this list?');
if (!yes) return;
setUiState('loading');
(async () => {
try {
await masto.v1.lists.remove(list.id);
setUiState('default');
onClose({
state: 'deleted',
});
} catch (e) {
console.error(e);
setUiState('error');
alert('Unable to delete list.');
}
})();
}}
>
Delete
</button>
)}
</div>
</form>
</main>
</div>
);
}
export default ListAddEdit;

View file

@ -194,6 +194,9 @@ button,
color: var(--text-color);
border: 1px solid var(--outline-color);
}
:is(button, .button).light:not(:disabled, .disabled):is(:hover, :focus) {
border-color: var(--outline-hover-color);
}
:is(button, .button).light.danger:not(:disabled, .disabled) {
color: var(--red-color);
}

View file

@ -1,8 +1,15 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useParams } from 'react-router-dom';
import './lists.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useNavigate, useParams } from 'react-router-dom';
import AccountBlock from '../components/account-block';
import Icon from '../components/icon';
import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit';
import Modal from '../components/modal';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
@ -14,7 +21,9 @@ const LIMIT = 20;
function List(props) {
const { masto, instance } = api();
const id = props?.id || useParams()?.id;
const navigate = useNavigate();
const latestItem = useRef();
// const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const listIterator = useRef();
async function fetchList(firstLoad) {
@ -55,37 +64,231 @@ function List(props) {
}
}
const [title, setTitle] = useState(`List`);
useTitle(title, `/l/:id`);
const [list, setList] = useState({ title: 'List' });
// const [title, setTitle] = useState(`List`);
useTitle(list.title, `/l/:id`);
useEffect(() => {
(async () => {
try {
const list = await masto.v1.lists.fetch(id);
setTitle(list.title);
setList(list);
// setTitle(list.title);
} catch (e) {
console.error(e);
}
})();
}, [id]);
const [showListAddEditModal, setShowListAddEditModal] = useState(false);
const [showManageMembersModal, setShowManageMembersModal] = useState(false);
return (
<Timeline
title={title}
id="list"
emptyText="Nothing yet."
errorText="Unable to load posts."
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel
allowFilters
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
<>
<Timeline
title={list.title}
id="list"
emptyText="Nothing yet."
errorText="Unable to load posts."
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel
allowFilters
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
}
headerEnd={
<Menu
portal={{
target: document.body,
}}
setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
<MenuItem
onClick={() =>
setShowListAddEditModal({
list,
})
}
>
<Icon icon="pencil" size="l" />
<span>Edit</span>
</MenuItem>
<MenuItem onClick={() => setShowManageMembersModal(true)}>
<Icon icon="group" size="l" />
<span>Manage members</span>
</MenuItem>
</Menu>
}
/>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success' && result.list) {
setList(result.list);
// reload();
} else if (result.state === 'deleted') {
navigate('/l');
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
{showManageMembersModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowManageMembersModal(false);
}
}}
>
<ListManageMembers listID={id} />
</Modal>
)}
</>
);
}
const MEMBERS_LIMIT = 10;
function ListManageMembers({ listID }) {
// Show list of members with [Remove] button
// API only returns 40 members at a time, so this need to be paginated with infinite scroll
// Show [Add] button after removing a member
const { masto, instance } = api();
const [members, setMembers] = useState([]);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const membersIterator = useRef();
async function fetchMembers(firstLoad) {
setShowMore(false);
setUIState('loading');
(async () => {
try {
if (firstLoad || !membersIterator.current) {
membersIterator.current = masto.v1.lists.listAccounts(listID, {
limit: MEMBERS_LIMIT,
});
}
const results = await membersIterator.current.next();
let { done, value } = results;
if (value?.length) {
if (firstLoad) {
setMembers(value);
} else {
setMembers(members.concat(value));
}
setShowMore(!done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
setUIState('error');
}
/>
})();
}
useEffect(() => {
fetchMembers(true);
}, []);
return (
<div class="sheet" id="list-manage-members-container">
<header>
<h2>Manage members</h2>
</header>
<main>
<ul>
{members.map((member) => (
<li key={member.id}>
<AccountBlock account={member} instance={instance} />
<RemoveAddButton account={member} listID={listID} />
</li>
))}
{showMore && uiState === 'default' && (
<InView as="li" onChange={(inView) => inView && fetchMembers()}>
<button type="button" class="light block" onClick={fetchMembers}>
Show more&hellip;
</button>
</InView>
)}
</ul>
</main>
</div>
);
}
function RemoveAddButton({ account, listID }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [removed, setRemoved] = useState(false);
return (
<button
type="button"
class={`light ${removed ? '' : 'danger'}`}
disabled={uiState === 'loading'}
onClick={() => {
if (removed) {
setUIState('loading');
(async () => {
try {
await masto.v1.lists.addAccount(listID, {
accountIds: [account.id],
});
setUIState('default');
setRemoved(false);
} catch (e) {
setUIState('error');
}
})();
} else {
const yes = confirm(`Remove ${account.username} from this list?`);
if (!yes) return;
setUIState('loading');
(async () => {
try {
await masto.v1.lists.removeAccount(listID, {
accountIds: [account.id],
});
setUIState('default');
setRemoved(true);
} catch (e) {
setUIState('error');
}
})();
}
}}
>
{removed ? 'Add' : 'Remove…'}
</button>
);
}

33
src/pages/lists.css Normal file
View file

@ -0,0 +1,33 @@
.list-form {
padding: 8px 0;
display: flex;
gap: 8px;
flex-direction: column;
}
.list-form-row :is(input, select) {
width: 100%;
}
.list-form-footer {
display: flex;
gap: 8px;
justify-content: space-between;
}
.list-form-footer button[type='submit'] {
padding-inline: 24px;
}
#list-manage-members-container ul {
display: block;
list-style: none;
padding: 8px 0;
margin: 0;
}
#list-manage-members-container ul li {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}

View file

@ -1,9 +1,13 @@
import { useEffect, useState } from 'preact/hooks';
import './lists.css';
import { useEffect, useReducer, useRef, useState } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import ListAddEdit from '../components/list-add-edit';
import Loader from '../components/loader';
import Menu from '../components/menu';
import Modal from '../components/modal';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
@ -12,6 +16,7 @@ function Lists() {
useTitle(`Lists`, `/l`);
const [uiState, setUiState] = useState('default');
const [reloadCount, reload] = useReducer((c) => c + 1, 0);
const [lists, setLists] = useState([]);
useEffect(() => {
setUiState('loading');
@ -26,7 +31,9 @@ function Lists() {
setUiState('error');
}
})();
}, []);
}, [reloadCount]);
const [showListAddEditModal, setShowListAddEditModal] = useState(false);
return (
<div id="lists-page" class="deck-container" tabIndex="-1">
@ -40,7 +47,15 @@ function Lists() {
</Link>
</div>
<h1>Lists</h1>
<div class="header-side" />
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => setShowListAddEditModal(true)}
>
<Icon icon="plus" size="l" alt="New list" />
</button>
</div>
</div>
</header>
<main>
@ -49,7 +64,22 @@ function Lists() {
{lists.map((list) => (
<li>
<Link to={`/l/${list.id}`}>
<Icon icon="list" /> <span>{list.title}</span>
<span>
<Icon icon="list" /> <span>{list.title}</span>
</span>
{/* <button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowListAddEditModal({
list,
});
}}
>
<Icon icon="pencil" />
</button> */}
</Link>
</li>
))}
@ -65,6 +95,26 @@ function Lists() {
)}
</main>
</div>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}