diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index d9d42e7a2..497c4c168 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -115,5 +115,10 @@ "day_other": "{{count}} days", "untilITurnItBackOn": "Until I turn it back on", "selectPauseDuration": "Select pause duration", - "setTimerForACoupleOfWeeks": "Select how many weeks you want to pause your participation" + "setTimerForACoupleOfWeeks": "Select how many weeks you want to pause your participation", + "updateHeader": "Edit this page's header", + "updateHeaderDesc": "This change will be visible to all loop members.", + "restHeaders": "Rest headers", + "resetHeadersDesc": "Reset your loop's headers to the default headers.", + "areYouSureYouWantToResetHeaders": "Are you sure you want to reset the loop's headers to defualt?" } diff --git a/app/src/Store.tsx b/app/src/Store.tsx index 55e1d266a..dcd7fbdfe 100644 --- a/app/src/Store.tsx +++ b/app/src/Store.tsx @@ -16,6 +16,7 @@ import { bulkyItemGetAllByChain, userUpdate, chainUpdate, + ChainHeaders, } from "./api"; import dayjs from "./dayjs"; import { OverlayContainsState, OverlayState } from "./utils/overlay_open"; @@ -43,6 +44,8 @@ export const StoreContext = createContext({ setTheme: (c: string) => {}, chain: null as Chain | null, chainUsers: [] as Array, + chainHeaders: undefined as ChainHeaders | undefined, + getChainHeader: (key: string, override: string) => override, listOfChains: [] as Array, route: [] as UID[], bags: [] as Bag[], @@ -74,6 +77,9 @@ export function StoreProvider({ }) { const [authUser, setAuthUser] = useState(null); const [chain, _setChain] = useState(null); + const [chainHeaders, setChainHeaders] = useState( + undefined, + ); const [chainUsers, setChainUsers] = useState>([]); const [listOfChains, setListOfChains] = useState>([]); const [route, setRoute] = useState([]); @@ -133,6 +139,7 @@ export function StoreProvider({ await storage.set("chain_uid", ""); setAuthUser(null); _setChain(null); + setChainHeaders(undefined); setListOfChains([]); setRoute([]); setBags([]); @@ -215,6 +222,7 @@ export function StoreProvider({ ) { let _chain: typeof chain = null; let _chainUsers: typeof chainUsers = []; + let _chainHeaders: typeof chainHeaders = undefined; let _route: typeof route = []; let _bags: typeof bags = []; let _isChainAdmin: typeof isChainAdmin = false; @@ -224,6 +232,7 @@ export function StoreProvider({ const res = await Promise.all([ chainGet(_chainUID, { addRules: true, + addHeaders: true, addTheme: true, addIsAppDisabled: true, }), @@ -233,6 +242,7 @@ export function StoreProvider({ bulkyItemGetAllByChain(_chainUID, _authUser.uid), ]); _chain = res[0].data; + _chainHeaders = ChainReadHeaders(_chain); _chainUsers = res[1].data; _route = res[2].data; _bags = res[3].data; @@ -252,6 +262,7 @@ export function StoreProvider({ await storage.set("chain_uid", _chainUID ? _chainUID : null); _setChain(_chain); + setChainHeaders(_chainHeaders); setChainUsers(_chainUsers); setRoute(_route); setBags(_bags); @@ -304,6 +315,7 @@ export function StoreProvider({ let _chain = await chainGet(chain.uid, { addRules: true, + addHeaders: true, addTheme: true, addIsAppDisabled: true, }); @@ -337,6 +349,7 @@ export function StoreProvider({ const isCurrentChain = uc.chain_uid === chain?.uid; return chainGet(uc.chain_uid, { addRules: isCurrentChain, + addHeaders: isCurrentChain, addTheme: isCurrentChain, addIsAppDisabled: true, }); @@ -353,6 +366,7 @@ export function StoreProvider({ setAuthUser(_authUser.data); setListOfChains(_listOfChains); _setChain(_chain); + setChainHeaders(ChainReadHeaders(_chain)); } } catch (err: any) { if (err === errLoopMustBeSelected) { @@ -392,6 +406,14 @@ export function StoreProvider({ return IsAuthenticated.OfflineLoggedIn; } + function getChainHeader(key: string, override: string): string { + if (chainHeaders && chainHeaders[key]) { + return chainHeaders[key]; + } + + return override; + } + return ( ; + export interface Chain { uid: UID; name: string; @@ -78,6 +80,7 @@ export interface Chain { published: boolean; open_to_new_members: boolean; rules_override?: string; + headers_override?: string; theme?: string; is_app_disabled?: boolean; } @@ -156,6 +159,7 @@ export function chainGet( chainUID: UID, o: { addRules?: boolean; + addHeaders?: boolean; addTheme?: boolean; addIsAppDisabled?: boolean; } = {}, @@ -164,6 +168,7 @@ export function chainGet( params: { chain_uid: chainUID, add_rules: o.addRules || false, + add_headers: o.addHeaders || false, add_theme: o.addTheme || false, add_is_app_disabled: o.addIsAppDisabled || false, }, diff --git a/app/src/components/EditHeaders.tsx b/app/src/components/EditHeaders.tsx new file mode 100644 index 000000000..cc489dac2 --- /dev/null +++ b/app/src/components/EditHeaders.tsx @@ -0,0 +1,161 @@ +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonIcon, + IonInput, + IonItem, + IonLabel, + IonList, + IonModal, + IonTitle, + IonToolbar, + useIonAlert, +} from "@ionic/react"; + +import type { IonModalCustomEvent } from "@ionic/core"; +import { RefObject, useContext, useState } from "react"; +import { StoreContext } from "../Store"; +import { OverlayEventDetail } from "@ionic/react/dist/types/components/react-component-lib/interfaces"; +import { useTranslation } from "react-i18next"; +import { chainUpdate } from "../api"; +import { refreshOutline } from "ionicons/icons"; + +export default function EditHeaders(props: { + initialHeader: string | null; + headerKey: string; + modal: RefObject; + didDismiss?: (e: IonModalCustomEvent>) => void; +}) { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const { chain, chainHeaders } = useContext(StoreContext); + + const [presentAlert] = useIonAlert(); + + const [text, setText] = useState(props.initialHeader); + + function modalInit() { + setText(props.initialHeader); + setError(""); + } + + function cancel() { + props.modal.current?.dismiss(); + } + + function handleSave() { + if (!chain?.uid) return; + const _headerKey = props.headerKey; + const newH = { + ...chainHeaders, + [_headerKey]: text, + }; + + chainUpdate({ + uid: chain.uid, + headers_override: JSON.stringify(newH), + }); + + props.modal.current?.dismiss("", "confirm"); + } + + function reset() { + if (!chain?.uid) return; + const handler = () => { + chainUpdate({ + uid: chain.uid, + headers_override: "", + }); + props.modal.current?.dismiss(); + }; + + presentAlert({ + header: t("resetHeaders"), + subHeader: t("areYouSureYouWantToResetHeaders"), + buttons: [ + { + text: t("cancel"), + }, + { + role: "destructive", + text: t("reset"), + handler, + }, + ], + }); + } + + return ( + + + + + {t("cancel")} + + {t("update")} + + + {t("save")} + + + + + + + {chain?.headers_override ? ( + + + +

{t("restHeaders")}

+

{t("resetHeadersDesc")}

+
+ + {t("reset")} + +
+ ) : null} + + {t("updateHeaderDesc")} + + + setText(e.detail.value + "")} + /> + +
+
+
+ ); +} diff --git a/app/src/components/HeaderTitle.tsx b/app/src/components/HeaderTitle.tsx new file mode 100644 index 000000000..630cc488b --- /dev/null +++ b/app/src/components/HeaderTitle.tsx @@ -0,0 +1,39 @@ +import { IonIcon, IonTitle } from "@ionic/react"; +import { construct } from "ionicons/icons"; +import { useLongPress } from "use-long-press"; + +interface Props { + isChainAdmin: boolean; + className: string; + headerText: string; + onEdit: () => void; +} + +export default function HeaderTitle({ + className, + isChainAdmin, + headerText, + onEdit, +}: Props) { + const longPressHeader = useLongPress(onEdit); + + if (isChainAdmin) { + return ( + + {headerText} + + + + ); + } + + return ( + + {headerText} + + ); +} diff --git a/app/src/pages/AddressList.tsx b/app/src/pages/AddressList.tsx index 35a36d14e..bacb1edd4 100644 --- a/app/src/pages/AddressList.tsx +++ b/app/src/pages/AddressList.tsx @@ -4,7 +4,6 @@ import { IonContent, IonHeader, IonIcon, - IonImg, IonItem, IonList, IonPage, @@ -12,7 +11,7 @@ import { IonTitle, IonToolbar, } from "@ionic/react"; -import { useContext } from "react"; +import { useContext, useRef } from "react"; import { useTranslation } from "react-i18next"; import { StoreContext } from "../Store"; import { @@ -25,10 +24,14 @@ import isPaused from "../utils/is_paused"; import IsPrivate from "../utils/is_private"; import OverlayPaused from "../components/OverlayPaused"; import OverlayAppDisabled from "../components/OverlayChainAppDisabled"; +import EditHeaders from "../components/EditHeaders"; +import HeaderTitle from "../components/HeaderTitle"; export default function AddressList() { const { chain, + setChain, + getChainHeader, chainUsers, route, authUser, @@ -39,13 +42,23 @@ export default function AddressList() { } = useContext(StoreContext); const { t } = useTranslation(); + const headerSheetModal = useRef(null); + + const headerKey = "addressList"; + + const headerText = getChainHeader("addressList", t("addresses")); + + function updateChain() { + setChain(chain?.uid, authUser); + } + return ( - {t("addresses")} + {headerText} {isChainAdmin ? ( - headerSheetModal.current?.present()} + isChainAdmin={isChainAdmin} className={"tw-font-serif".concat( isThemeDefault ? " tw-text-red dark:tw-text-red-contrast" : "", )} - > - {t("addresses")} - + /> + {isChainAdmin ? ( + + ) : null} - {t("whereIsTheBag")} + {headerText} {isChainAdmin ? ( @@ -262,19 +281,36 @@ export default function BagsList() { - headerSheetModal.current?.present()} + isChainAdmin={isChainAdmin} className={"tw-font-serif".concat( isThemeDefault ? " tw-text-green" : "", )} - > - {t("whereIsTheBag")} - + /> - - {t("clickOnBagToChangeHolder")} - + + {isChainAdmin ? ( + + {subHeaderText} + subHeaderSheetModal.current?.present()} + /> + + ) : ( + + {subHeaderText} + + )} +
+ {isChainAdmin ? ( +
+ + +
+ ) : null}
{/* Background SVG */} (null); const [modalDesc, setModalDesc] = useState({ title: "", message: "" }); const refModalDesc = useRef(null); + const headerSheetModal = useRef(null); + + const headerKey = "bulkyList"; + + const headerText = getChainHeader(headerKey, t("bulkyItemsTitle")); + + function refreshChain() { + setChain(chain?.uid, authUser); + } function handleClickDelete(id: number) { const handler = async () => { @@ -159,7 +167,7 @@ export default function BulkyList() { - {t("bulkyItemsTitle")} + {headerText} - headerSheetModal.current?.present()} + isChainAdmin={isChainAdmin} className={"tw-font-serif tw-font-bold".concat( isThemeDefault ? " tw-text-blue" : "", )} - > - {t("bulkyItemsTitle")} - + /> {bulkyItems.map((bulkyItem, i) => { @@ -328,6 +336,15 @@ export default function BulkyList() { ))}
+ + {isChainAdmin ? ( + + ) : null}
{/* Background SVGs */} (null); + const headerSheetModal = useRef(null); + + const headerKey = "helpList"; + + const headerText = getChainHeader(headerKey, t("howDoesItWork")); const rules = useMemo(() => { if (chain?.rules_override) { @@ -96,7 +104,7 @@ export default function HelpList() { - {t("howDoesItWork")} + {headerText} {isChainAdmin ? ( @@ -117,14 +125,14 @@ export default function HelpList() { > - headerSheetModal.current?.present()} + isChainAdmin={isChainAdmin} className={`tw-font-serif tw-font-bold ${ isThemeDefault ? "tw-text-purple " : "" }`} - > - {t("howDoesItWork")} - + /> @@ -183,7 +191,14 @@ export default function HelpList() { modal={modal} didDismiss={refreshChain} /> - + {isChainAdmin ? ( + + ) : null} -
{t("logout")} @@ -504,6 +541,22 @@ export default function Settings() { + {isChainAdmin ? ( +
+ + +
+ ) : null} ); diff --git a/frontend/src/api/chain.ts b/frontend/src/api/chain.ts index 0395afef5..788f6ad5e 100644 --- a/frontend/src/api/chain.ts +++ b/frontend/src/api/chain.ts @@ -22,6 +22,7 @@ interface RequestChainGetAllParams { filter_genders?: string[]; filter_out_unpublished?: boolean; add_rules?: boolean; + add_headers?: boolean; } export function chainGetAll(params?: RequestChainGetAllParams) { return window.axios.get("/v2/chain/all", { params }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b97e9c9ab..ec5f44970 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -51,6 +51,7 @@ export interface Chain { total_hosts?: number; route_privacy?: number; rules_override?: string; + headers_override?: string; theme?: string; is_app_disabled?: boolean; } diff --git a/frontend/src/components/ChainsList.tsx b/frontend/src/components/ChainsList.tsx index 9f1018a73..af2594401 100644 --- a/frontend/src/components/ChainsList.tsx +++ b/frontend/src/components/ChainsList.tsx @@ -50,6 +50,7 @@ export default function ChainsList({ chains, setChains }: Props) { await chainGetAll({ filter_out_unpublished: false, add_rules: true, + add_headers: true, }) ).data; } else { diff --git a/server/internal/controllers/chain.go b/server/internal/controllers/chain.go index 6bb43fe49..8381aed42 100644 --- a/server/internal/controllers/chain.go +++ b/server/internal/controllers/chain.go @@ -109,6 +109,7 @@ func ChainGet(c *gin.Context) { var query struct { ChainUID string `form:"chain_uid" binding:"required"` AddRules bool `form:"add_rules" binding:"omitempty"` + AddHeaders bool `form:"add_headers" binding:"omitempty"` AddTotals bool `form:"add_totals" binding:"omitempty"` AddTheme bool `form:"add_theme" binding:"omitempty"` AddIsAppDisabled bool `form:"add_is_app_disabled" binding:"omitempty"` @@ -125,6 +126,10 @@ func ChainGet(c *gin.Context) { sql += `, chains.rules_override` } + if query.AddHeaders { + sql += `, + chains.headers_override` + } if query.AddTheme { sql += `, chains.theme` @@ -157,6 +162,9 @@ func ChainGet(c *gin.Context) { if query.AddRules { body.RulesOverride = &chain.RulesOverride } + if query.AddHeaders { + body.HeadersOverride = &chain.HeadersOverride + } if query.AddTheme { body.Theme = &chain.Theme } @@ -332,6 +340,7 @@ func ChainUpdate(c *gin.Context) { Sizes *[]string `json:"sizes,omitempty"` Genders *[]string `json:"genders,omitempty"` RulesOverride *string `json:"rules_override,omitempty"` + HeadersOverride *string `json:"headers_override,omitempty"` Published *bool `json:"published,omitempty"` OpenToNewMembers *bool `json:"open_to_new_members,omitempty"` Theme *string `json:"theme,omitempty"` @@ -394,6 +403,9 @@ func ChainUpdate(c *gin.Context) { if body.RulesOverride != nil { valuesToUpdate["rules_override"] = *(body.RulesOverride) } + if body.HeadersOverride != nil { + valuesToUpdate["headers_override"] = *(body.HeadersOverride) + } if body.Published != nil { valuesToUpdate["published"] = *(body.Published) diff --git a/server/internal/models/chain.go b/server/internal/models/chain.go index 98500fdd1..78806ecc7 100644 --- a/server/internal/models/chain.go +++ b/server/internal/models/chain.go @@ -28,6 +28,7 @@ type Chain struct { Published bool OpenToNewMembers bool RulesOverride string + HeadersOverride string Sizes []string `gorm:"serializer:json"` Genders []string `gorm:"serializer:json"` UserChains []UserChain @@ -56,6 +57,7 @@ type ChainResponse struct { TotalMembers *int `json:"total_members,omitempty"` TotalHosts *int `json:"total_hosts,omitempty"` RulesOverride *string `json:"rules_override,omitempty"` + HeadersOverride *string `json:"headers_override,omitempty"` Theme *string `json:"theme,omitempty"` IsAppDisabled *bool `json:"is_app_disabled,omitempty"` RoutePrivacy *int `json:"route_privacy,omitempty"`