diff --git a/app/global.d.ts b/app/global.d.ts
index ead47d2..fc318d5 100644
--- a/app/global.d.ts
+++ b/app/global.d.ts
@@ -1 +1,8 @@
///
+///
+
+enum TabView {
+ Settings,
+ Contacts,
+ About,
+}
diff --git a/app/setting/components/TabContent.js b/app/setting/components/TabContent.js
new file mode 100644
index 0000000..992c251
--- /dev/null
+++ b/app/setting/components/TabContent.js
@@ -0,0 +1,42 @@
+import { ContactList } from './contactList';
+import {
+ createAuthView,
+ createShareEmailInput,
+ createSignOutButton,
+} from '../util/createViews';
+import { useSettings } from '../context/SettingsContext';
+
+// This file should be used to define the different tabs shown in settings.
+// The tabs are defined as functions that return the pseudo-JSX for the tab content.
+
+export const SettingsTab = () => {
+ const store = useSettings();
+ const isUserSignedIn = !!store.getAuthToken();
+ return View(
+ { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
+ [
+ isUserSignedIn ? createShareEmailInput() : createAuthView(),
+ isUserSignedIn && View({}, createSignOutButton()),
+ ],
+ );
+};
+
+export const ContactsTab = () => {
+ const store = useSettings();
+ if (!store.getAuthToken()) {
+ console.error('No access token found');
+ return Text({}, 'No access token found. Please sign in first.');
+ }
+ const items = ContactList();
+ return items.length
+ ? items
+ : Text({}, 'No contacts found. Add some in your Settings tab.');
+};
+
+export const AboutTab = () => Text({ style: { fontSize: '12px' } }, 'TODO');
+
+export const TAB_COMPONENTS = {
+ Settings: SettingsTab,
+ Contacts: ContactsTab,
+ About: AboutTab,
+};
diff --git a/app/setting/components/button.js b/app/setting/components/button.js
index 616aa4e..87d8e4d 100644
--- a/app/setting/components/button.js
+++ b/app/setting/components/button.js
@@ -1,4 +1,4 @@
-export default PrimaryButton = ({ label, onClick }) => {
+export const PrimaryButton = ({ label, onClick }) => {
return Button({
label,
onClick,
@@ -11,3 +11,23 @@ export default PrimaryButton = ({ label, onClick }) => {
},
});
};
+
+export const XButton = (onClick) => {
+ return Button({
+ label: Image({ style: { width: '10px' }, src: xb64 }),
+ onClick,
+ style: {
+ background: '#FF0000',
+ borderRadius: '12px',
+ boxShadow: 'none',
+ color: '#FFF',
+ fontSize: '12px',
+ minWidth: '16px',
+ minHeight: '16px',
+ padding: '0',
+ },
+ });
+};
+
+const xb64 =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABdklEQVRIiZ2V3YrCMBBGi+xFKcUHK1L27cQLKRKWRUR8NFl63e/sRRONbTqNDgRCO7+HzExRRALsJHVAXbwpQA04SW3qZyGpAXpgAK5A9YbzytsM3kcDPBW887skIrnkBAEqSefYUNIdaIJCC/SSCAHCXdIN2C45l1RLui7Y9kBbAHtfWkoG4CZpVskES9JW0iEoujiLhLzg8jbnadaTCtzDBiiB44rBDdgyvpZr9D11OqCc8iwBt4bLHwuLmzmflO5C5kHeuD+xGM8uB1ceFiuIL3UJRT4WI0gurh8Ly8aIsWF8MXHQ2d07//oke6uJXhCx0IyW8/OSNwPXZTWINVty7ubsysAyMHbwZUVnjgs/co1ZhP9fS6qB32xcQA6WE9E7Z2zGLgsX6/Mn2USS1ppxAFwhqWVcc6lxbc4W7Gbsge+g2Ei6x2UCXSrzRJBS0nFi+wfsYqWCcVH3fDBbiGaXX5U74qUfKbbAwcJiBKkk7R9YvPwD3quO5XgK8UIAAAAASUVORK5CYII=';
diff --git a/app/setting/components/contactList.js b/app/setting/components/contactList.js
new file mode 100644
index 0000000..1031de4
--- /dev/null
+++ b/app/setting/components/contactList.js
@@ -0,0 +1,48 @@
+import { useSettings } from '../context/SettingsContext';
+import { removeFilePermissionById } from '../util/google';
+import { XButton } from './button';
+
+/**
+ * Creates a list of Contact elements to display
+ * @param {object} contacts list of contacts
+ * @returns {Array} list of Contact elements to display
+ */
+export const ContactList = () => {
+ const settings = useSettings();
+ const accessToken = settings.getAuthToken();
+
+ if (!accessToken) {
+ console.error('No access token found');
+ return [];
+ }
+ const email = settings.getEmail();
+ const contacts = settings.getSetting(`contactsList_${email}`) || {};
+
+ const contactsMap = new Map(Object.entries(contacts));
+ const list = [];
+ for (const [contact, permissionId] of contactsMap) {
+ list.push(
+ Contact(contact, () => {
+ removeFilePermissionById(permissionId, accessToken).then((result) => {
+ if (result.success) {
+ console.log('Successfully removed contact:', contact);
+ contactsMap.delete(contact);
+ const updatedContacts = Object.fromEntries(contactsMap);
+ settings.setSetting(`contactsList_${email}`, updatedContacts);
+ } else {
+ console.error('Failed to remove contact:', contact);
+ }
+ });
+ }),
+ );
+ }
+
+ return list;
+};
+
+const Contact = (contact, onClick) => {
+ return View({ style: { display: 'flex', gap: '10px' } }, [
+ contact,
+ XButton(onClick),
+ ]);
+};
diff --git a/app/setting/components/tabs.js b/app/setting/components/tabs.js
new file mode 100644
index 0000000..26627bb
--- /dev/null
+++ b/app/setting/components/tabs.js
@@ -0,0 +1,36 @@
+import { useSettings } from '../context/SettingsContext';
+
+const tabs = ['Settings', 'Contacts', 'About'];
+
+/**
+ * Create the tabs for the settings page
+ * @param {string} activeTab the currently active tab
+ * @param {*} setSetting function to set the active tab
+ * @returns
+ */
+export const Tabs = () => {
+ const store = useSettings();
+ const activeTab = store.getState().activeTab;
+ const tabButtons = tabs.map((tabName) => {
+ const isActive = tabName === activeTab;
+ return Tab(tabName, isActive, () => store.setState('activeTab', tabName));
+ });
+
+ return View({ style: { display: 'flex', marginBottom: '15px' } }, tabButtons);
+};
+
+const Tab = (label, isActive, onClick) => {
+ const btn = Button({
+ label,
+ onClick,
+ style: {
+ flex: '1',
+ boxShadow: 'none',
+ background: isActive ? '#000' : '#FFF',
+ color: isActive ? '#FFF' : '#000',
+ display: 'inline',
+ fontSize: '12px',
+ },
+ });
+ return btn;
+};
diff --git a/app/setting/components/textInput.js b/app/setting/components/textInput.js
new file mode 100644
index 0000000..8143755
--- /dev/null
+++ b/app/setting/components/textInput.js
@@ -0,0 +1,25 @@
+export const Input = (label, placeholder, onChange) => {
+ return TextInput({
+ label,
+ placeholder,
+ onChange,
+ subStyle: {
+ border: 'thin rgba(0,0,0,0.1) solid',
+ borderRadius: '8px',
+ boxSizing: 'content-box',
+ color: '#000',
+ height: '.8em',
+ lineHeight: '1.5em',
+ marginTop: '-16px',
+ padding: '8px',
+ paddingTop: '1.2em',
+ },
+ labelStyle: {
+ color: '#555',
+ fontSize: '0.8em',
+ paddingLeft: '8px',
+ position: 'relative',
+ top: '0.2em',
+ },
+ });
+};
diff --git a/app/setting/components/toast.js b/app/setting/components/toast.js
new file mode 100644
index 0000000..9ddf72e
--- /dev/null
+++ b/app/setting/components/toast.js
@@ -0,0 +1,6 @@
+export const VisibleToast = (message) => {
+ return Toast({
+ message,
+ visible: true,
+ });
+};
diff --git a/app/setting/context/SettingsContext.js b/app/setting/context/SettingsContext.js
new file mode 100644
index 0000000..543b55c
--- /dev/null
+++ b/app/setting/context/SettingsContext.js
@@ -0,0 +1,41 @@
+/**
+ * This file contains the settings context and the settings store.
+ * The settings store is used to manage the settings of the app
+ * and provide context to the settings components.
+ * @param {object} storage settingsStorage object
+ * @returns
+ */
+let globalSettingsStore = null;
+
+export const createSettingsStore = ({
+ settings,
+ settingsStorage: settingsStore,
+}) => {
+ const store = {
+ setState(key, value) {
+ settingsStore.setItem(key, value);
+ },
+ getState() {
+ return settings;
+ },
+ setSetting: (key, value) => settingsStore.setItem(key, value),
+ getSetting: (key) => settingsStore.getItem(key),
+ getAuthToken: () => {
+ const authData = JSON.parse(settingsStore.getItem('googleAuthData'));
+ return authData?.access_token || '';
+ },
+ getEmail: () => {
+ const authData = JSON.parse(settingsStore.getItem('googleAuthData'));
+ return authData?.email;
+ },
+ };
+ globalSettingsStore = store;
+ return store;
+};
+
+export const useSettings = () => {
+ if (!globalSettingsStore) {
+ throw new Error('Settings store must be initialized before use');
+ }
+ return globalSettingsStore;
+};
diff --git a/app/setting/index.js b/app/setting/index.js
index bd5675d..78cfbcf 100644
--- a/app/setting/index.js
+++ b/app/setting/index.js
@@ -1,9 +1,6 @@
-import {
- GOOGLE_API_CLIENT_ID,
- GOOGLE_API_CLIENT_SECRET,
- GOOGLE_API_REDIRECT_URI,
-} from '../google-api-constants';
-import PrimaryButton from './components/button';
+import { TAB_COMPONENTS } from './components/TabContent';
+import { Tabs } from './components/tabs';
+import { createSettingsStore } from './context/SettingsContext';
AppSettingsPage({
state: {
@@ -28,129 +25,36 @@ AppSettingsPage({
if (this.isTokenExpired() || !this.state.googleAuthData) {
this.state.googleAuthData = null;
}
+
+ if (!props.settingsStorage.getItem('activeTab')) {
+ props.settingsStorage.setItem('activeTab', 'Settings');
+ }
console.log('state:', this.state);
},
build(props) {
this.setState(props);
-
- const nowTag = new Date().toISOString().substring(0, 19);
- if (props.settingsStorage.getItem('now') !== nowTag)
- props.settingsStorage.setItem('now', nowTag);
-
- const userSignedIn = !!this.state.googleAuthData;
- const signInBtn = PrimaryButton({
- label: 'Sign in',
- });
-
- const clearBtn = PrimaryButton({
- label: 'Clear',
- onClick: () => {
- console.log(
- 'before clear',
- this.state.props.settingsStorage.toObject(),
- );
- // this.state.props.settingsStorage.clear();
- props.settingsStorage.setItem('googleAuthData', null);
- props.settingsStorage.setItem('googleAuthCode', null);
- this.state.googleAuthData = '';
- console.log('after clear', this.state.props.settingsStorage.toObject());
- },
- });
-
- const clearDiv = View(
- {
- style: {
- display: 'inline',
- },
- },
- clearBtn,
- );
-
- const shareEmailInput = TextInput({
- label: 'Share with others',
- placeholder: 'Enter email address...',
- onChange: async (value) => {
- console.log('emailInput', value);
- await shareFilesWithEmail(
- value,
- this.state.googleAuthData.access_token,
- );
- },
- subStyle: {
- border: 'thin rgba(0,0,0,0.1) solid',
- borderRadius: '8px',
- boxSizing: 'content-box',
- color: '#000',
- height: '1.5em',
- lineHeight: '1.5em',
- marginTop: '-16px',
- padding: '8px',
- paddingTop: '1.2em',
- },
- labelStyle: {
- color: '#555',
- fontSize: '0.8em',
- paddingLeft: '8px',
- position: 'relative',
- top: '0.2em',
- },
- });
-
- const tt = Text(
+ const store = createSettingsStore(props);
+ const currentTab = store.getState().activeTab || 'Settings';
+ const TabComponent = TAB_COMPONENTS[currentTab];
+ return Section(
{
style: {
- fontSize: '12px',
- marginTop: '10px',
+ padding: '10px',
},
},
- `Google Auth Data: ${JSON.stringify(this.state.googleAuthData)}`,
- );
-
- const authDiv = Auth({
- label: signInBtn,
- authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
- requestTokenUrl: 'https://oauth2.googleapis.com/token',
- scope: 'https://www.googleapis.com/auth/drive',
- clientId: GOOGLE_API_CLIENT_ID,
- clientSecret: GOOGLE_API_CLIENT_SECRET,
- oAuthParams: {
- redirect_uri: GOOGLE_API_REDIRECT_URI,
- response_type: 'code',
- include_granted_scopes: 'true',
- access_type: 'offline',
- prompt: 'consent',
- },
- onAccessToken: (token) => {
- console.log('onAccessToken', token);
- },
- onReturn: async (authBody) => {
- console.log('onReturn', authBody);
- // this.state.props.settingsStorage.setItem('googleAuthCode', authBody.code)
- const authData = await requestGoogleAuthData(authBody);
- authData.requested_at = new Date();
- authData.expires_at = new Date(
- authData.requested_at.getTime() + authData.expires_in * 1000,
- );
- this.state.props.settingsStorage.setItem(
- 'googleAuthData',
- JSON.stringify(authData),
- );
- console.log('authData', this.state.googleAuthData);
- },
- });
-
- const signedInView = [clearDiv, shareEmailInput];
-
- return View(
- {
- style: {
- padding: '20px',
- display: 'flex',
- flexDirection: 'column',
- gap: '10px',
- },
- },
- [userSignedIn ? signedInView : authDiv],
+ [
+ Tabs(),
+ View(
+ {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ },
+ },
+ TabComponent(),
+ ),
+ ],
);
},
@@ -164,56 +68,3 @@ AppSettingsPage({
return now >= expiresAt;
},
});
-
-/**
- * Request Google Auth Data from Google API after receiving auth code
- * @param authResponse the auth code from Google API
- * @returns access token and other data for using API
- */
-const requestGoogleAuthData = async (authResponse) => {
- const params = {
- grant_type: 'authorization_code',
- client_id: GOOGLE_API_CLIENT_ID,
- client_secret: GOOGLE_API_CLIENT_SECRET,
- redirect_uri: GOOGLE_API_REDIRECT_URI,
- code: authResponse.code,
- };
-
- const body = Object.keys(params)
- .map(
- (key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`,
- )
- .join('&');
-
- // console.log(body)
- const data = await fetch('https://oauth2.googleapis.com/token', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- },
- body: body,
- });
- return await data.json();
-};
-
-const shareFilesWithEmail = async (address, accessToken) => {
- const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGg,RiN3Ayp4fxYtzU'; // todo remove hardcode, share with folder
- const body = JSON.stringify({
- role: 'reader',
- type: 'user',
- value: address,
- });
-
- const response = await fetch(
- `https://www.googleapis.com/drive/v2/files/${fileId}/permissions`,
- {
- method: 'POST',
- body,
- headers: {
- Authorization: `Bearer ${accessToken}`,
- 'Content-Type': 'application/json',
- },
- },
- );
- return response;
-};
diff --git a/app/setting/util/createViews.js b/app/setting/util/createViews.js
new file mode 100644
index 0000000..4a04cbf
--- /dev/null
+++ b/app/setting/util/createViews.js
@@ -0,0 +1,84 @@
+import { Buffer } from 'buffer';
+import {
+ GOOGLE_API_CLIENT_ID,
+ GOOGLE_API_CLIENT_SECRET,
+ GOOGLE_API_REDIRECT_URI,
+} from '../../google-api-constants';
+import { Input } from '../components/textInput';
+import { PrimaryButton } from '../components/button';
+import { shareFilesWithEmail, requestGoogleAuthData } from '../util/google';
+import { useSettings } from '../context/SettingsContext';
+
+export const createAuthView = () => {
+ const settings = useSettings();
+ const authView = Auth({
+ label: PrimaryButton({
+ label: 'Sign in',
+ }),
+ authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ requestTokenUrl: 'https://oauth2.googleapis.com/token',
+ scope: 'openid email https://www.googleapis.com/auth/drive',
+ clientId: GOOGLE_API_CLIENT_ID,
+ clientSecret: GOOGLE_API_CLIENT_SECRET,
+ oAuthParams: {
+ redirect_uri: GOOGLE_API_REDIRECT_URI,
+ response_type: 'code',
+ include_granted_scopes: 'true',
+ access_type: 'offline',
+ prompt: 'consent',
+ },
+ onAccessToken: (token) => {
+ console.log('onAccessToken', token);
+ },
+ onReturn: async (authBody) => {
+ console.log('onReturn', authBody);
+ const authData = await requestGoogleAuthData(authBody);
+ console.log('authData', authData);
+ authData.requested_at = new Date();
+ authData.expires_at = new Date(
+ authData.requested_at.getTime() + authData.expires_in * 1000,
+ );
+ const id = JSON.parse(
+ Buffer.from(authData.id_token.split('.')[1], 'base64'),
+ );
+ authData.email = id.email;
+ settings.setSetting('googleAuthData', JSON.stringify(authData));
+ console.log('authData', authData);
+ },
+ });
+ return authView;
+};
+
+export const createShareEmailInput = () => {
+ const settings = useSettings();
+ const email = settings.getEmail();
+ const contactsList = settings.getSetting(`contactsList_${email}`) || {};
+ const shareEmailInput = Input(
+ 'Share with others',
+ 'Enter email address...',
+ async (value) => {
+ console.log('emailInput', value);
+ const result = await shareFilesWithEmail(value, settings.getAuthToken());
+ if (!result.success) {
+ settings.setSetting('shareError', true);
+ return;
+ }
+ contactsList[value] = result.permissionId;
+ settings.setSetting(`contactsList_${email}`, contactsList);
+ },
+ );
+ return shareEmailInput;
+};
+
+export const createSignOutButton = () => {
+ const settings = useSettings();
+ const signOutBtn = PrimaryButton({
+ label: 'Sign out',
+ onClick: () => {
+ settings.setSetting('googleAuthData', null);
+ settings.setSetting('googleAuthCode', null);
+ console.log('Signed out');
+ },
+ });
+ return signOutBtn;
+};
diff --git a/app/setting/util/google.js b/app/setting/util/google.js
new file mode 100644
index 0000000..c950703
--- /dev/null
+++ b/app/setting/util/google.js
@@ -0,0 +1,84 @@
+import {
+ GOOGLE_API_CLIENT_ID,
+ GOOGLE_API_CLIENT_SECRET,
+ GOOGLE_API_REDIRECT_URI,
+} from '../../google-api-constants';
+
+/**
+ * Request Google Auth Data from Google API after receiving auth code
+ * @param authResponse the auth code from Google API
+ * @returns access token and other data for using API
+ */
+export const requestGoogleAuthData = async (authResponse) => {
+ const params = {
+ grant_type: 'authorization_code',
+ client_id: GOOGLE_API_CLIENT_ID,
+ client_secret: GOOGLE_API_CLIENT_SECRET,
+ redirect_uri: GOOGLE_API_REDIRECT_URI,
+ code: authResponse.code,
+ };
+
+ const body = Object.keys(params)
+ .map(
+ (key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`,
+ )
+ .join('&');
+
+ const data = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: body,
+ });
+ return await data.json();
+};
+
+export const shareFilesWithEmail = async (address, accessToken) => {
+ const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGgRiN3Ayp4fxYtzU'; // todo remove hardcode, share with folder
+ const body = JSON.stringify({
+ role: 'reader',
+ type: 'user',
+ value: address,
+ });
+
+ try {
+ const response = await fetch(
+ `https://www.googleapis.com/drive/v2/files/${fileId}/permissions`,
+ {
+ method: 'POST',
+ body,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+ if (!response.ok) {
+ throw new Error(`Failed to share file: ${response.statusText}`);
+ }
+ const result = await response.json();
+ return { success: true, permissionId: result.id };
+ } catch (error) {
+ return { success: false };
+ }
+};
+
+export const removeFilePermissionById = async (permissionId, accessToken) => {
+ const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGgRiN3Ayp4fxYtzU'; // TODO: Remove hardcoding
+
+ const response = await fetch(
+ `https://www.googleapis.com/drive/v2/files/${fileId}/permissions/${permissionId}`,
+ {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to remove permission: ${response.statusText}`);
+ }
+ return { success: true };
+};
diff --git a/package.json b/package.json
index 4553a74..bd73145 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
},
"dependencies": {
"@silver-zepp/easy-storage": "^1.6.7-b",
- "@zeppos/zml": "^0.0.29"
+ "@zeppos/zml": "^0.0.29",
+ "buffer": "^4.9.2"
},
"scripts": {
"prettier:format": "prettier --write \"**/*.{ts,tsx,js,md,mdx,css,yaml}\"",