diff --git a/bun.lockb b/bun.lockb index d7a3099..c077f00 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/npm/src/index.ts b/npm/src/index.ts index 196b11c..8e3559f 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -150,7 +150,6 @@ function placeAllParts( board.width - extraSpace, board.length - extraSpace, ); - console.log({ boardRect, board, extraSpace }); // Fill the bin const partsToPlace = unplacedPartsArray diff --git a/package.json b/package.json index a405e6f..104b137 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@aklinker1/check": "^1.3.1", + "firebaseui": "^6.1.0", "nanoid": "^5.0.7", "panzoom": "^9.4.3", "standard-version": "^9.5.0", diff --git a/web/components/BomTab.vue b/web/components/BomTab.vue index 2c30759..1566b78 100644 --- a/web/components/BomTab.vue +++ b/web/components/BomTab.vue @@ -1,9 +1,10 @@ + + + + {{ doc.name }} + + by {{ doc.owner.name }} • + + {{ doc.defaultWorkspace.name }} Revision + + + + {{ url }} + + diff --git a/web/components/PartListItem.vue b/web/components/PartListItem.vue index 4b5d1b1..e8180fb 100644 --- a/web/components/PartListItem.vue +++ b/web/components/PartListItem.vue @@ -21,7 +21,7 @@ const fontSize = usePx(() => ), ); -const showPartNumbers = useShowPartNumbers(); +const { showPartNumbers } = useProjectSettings(); diff --git a/web/components/ProfileTab.vue b/web/components/ProfileTab.vue new file mode 100644 index 0000000..8318a84 --- /dev/null +++ b/web/components/ProfileTab.vue @@ -0,0 +1,10 @@ + + + + + Sign In + + + diff --git a/web/components/ProjectList.vue b/web/components/ProjectList.vue new file mode 100644 index 0000000..b335262 --- /dev/null +++ b/web/components/ProjectList.vue @@ -0,0 +1,47 @@ + + + + + + + + + + + Loading... + + + + No projects, + add one + + + + diff --git a/web/components/ProjectSidebar.vue b/web/components/ProjectSidebar.vue index e8af52a..9fd24ce 100644 --- a/web/components/ProjectSidebar.vue +++ b/web/components/ProjectSidebar.vue @@ -1,10 +1,8 @@ - + - + @@ -40,8 +62,11 @@ watch(distanceUnit, (newUnit, oldUnit) => { - - Settings are saved when returning to the website. - - + + Save Changes + Reset + + diff --git a/web/components/StockMatrixInput.vue b/web/components/StockMatrixInput.vue index 3daf416..2bbc0be 100644 --- a/web/components/StockMatrixInput.vue +++ b/web/components/StockMatrixInput.vue @@ -1,46 +1,48 @@ - - - - - {{ error }} - - + + + + {{ err }} + diff --git a/web/components/StockTab.vue b/web/components/StockTab.vue index 39a6327..4e807ef 100644 --- a/web/components/StockTab.vue +++ b/web/components/StockTab.vue @@ -1,9 +1,33 @@ - + + + + + Save + Reset + + diff --git a/web/components/TabListItem.vue b/web/components/TabListItem.vue index 66362ec..79365d2 100644 --- a/web/components/TabListItem.vue +++ b/web/components/TabListItem.vue @@ -14,7 +14,7 @@ const route = useRoute(); { + if (user.value == null) return local; + return firebase; + }); +} diff --git a/web/composables/useAccountServiceId.ts b/web/composables/useAccountServiceId.ts new file mode 100644 index 0000000..a9d3b83 --- /dev/null +++ b/web/composables/useAccountServiceId.ts @@ -0,0 +1,4 @@ +export default function () { + const account = useAccountService(); + return computed(() => account.value.id); +} diff --git a/web/composables/useBladeWidth.ts b/web/composables/useBladeWidth.ts deleted file mode 100644 index 3048a5a..0000000 --- a/web/composables/useBladeWidth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Distance } from '@aklinker1/cutlist'; - -/** - * Returns the blade width in standard units based off the settings. - */ -export default function () { - const bladeWidth = useBladeWidthSetting(); - const unit = useDistanceUnit(); - return computed(() => new Distance(bladeWidth.value + unit.value).m); -} diff --git a/web/composables/useBladeWidthSetting.ts b/web/composables/useBladeWidthSetting.ts deleted file mode 100644 index 26caed3..0000000 --- a/web/composables/useBladeWidthSetting.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stores the preference from the settings page directly. Unless you're on the - * settings page, you should probably use `useBladeWidth` instead for the - * standardized value. - */ -export default createGlobalState(() => - useLocalStorage('@cutlist/blade-width', '0.125'), -); diff --git a/web/composables/useBoardLayoutsQuery.ts b/web/composables/useBoardLayoutsQuery.ts index 9623f1e..d081821 100644 --- a/web/composables/useBoardLayoutsQuery.ts +++ b/web/composables/useBoardLayoutsQuery.ts @@ -1,11 +1,16 @@ import { useQuery } from '@tanstack/vue-query'; -import { generateBoardLayouts } from '@aklinker1/cutlist'; +import { + Distance, + generateBoardLayouts, + type Config, +} from '@aklinker1/cutlist'; export default function () { const loader = useOnshapeLoader(); const url = useAssemblyUrl(); - const config = useCutlistConfig(); - const stock = useStock(); + const { bladeWidth, optimize, extraSpace, distanceUnit, stock } = + useProjectSettings(); + const parseStock = useParseStock(); const partsQuery = useQuery({ queryKey: ['onshape', 'board-layouts', url], @@ -15,9 +20,23 @@ export default function () { const layouts = computed(() => { const parts = partsQuery.data.value; - if (parts == null) return undefined; + if ( + parts == null || + bladeWidth.value == null || + extraSpace.value == null || + optimize.value == null || + distanceUnit.value == null || + stock.value == null + ) + return; - return generateBoardLayouts(toRaw(parts), stock.value, config.value); + const config: Config = { + bladeWidth: new Distance(bladeWidth.value + distanceUnit.value).m, + extraSpace: new Distance(extraSpace.value + distanceUnit.value).m, + optimize: optimize.value === 'Cuts' ? 'cuts' : 'space', + precision: 1e-5, + }; + return generateBoardLayouts(toRaw(parts), parseStock(stock.value), config); }); return { diff --git a/web/composables/useCurrentUser.ts b/web/composables/useCurrentUser.ts new file mode 100644 index 0000000..291a5f9 --- /dev/null +++ b/web/composables/useCurrentUser.ts @@ -0,0 +1,10 @@ +export default createSharedComposable(() => { + const user = ref(firebaseAuth.currentUser); + firebaseAuth.authStateReady().then(() => { + user.value = firebaseAuth.currentUser; + }); + firebaseAuth.onAuthStateChanged(() => { + user.value = firebaseAuth.currentUser; + }); + return user; +}); diff --git a/web/composables/useCutlistConfig.ts b/web/composables/useCutlistConfig.ts deleted file mode 100644 index b91a0df..0000000 --- a/web/composables/useCutlistConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from '@aklinker1/cutlist'; - -export default createSharedComposable(() => { - const bladeWidth = useBladeWidth(); - const optimize = useOptimizeFor(); - const extraSpace = useExtraSpace(); - - return computed(() => ({ - bladeWidth: bladeWidth.value, - optimize: optimize.value, - extraSpace: extraSpace.value, - precision: 1e-5, - })); -}); diff --git a/web/composables/useDeleteSettingsMutation.ts b/web/composables/useDeleteSettingsMutation.ts new file mode 100644 index 0000000..6e2e9e3 --- /dev/null +++ b/web/composables/useDeleteSettingsMutation.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/vue-query'; + +export default function () { + const accountService = useAccountService(); + const client = useQueryClient(); + + return useMutation({ + mutationFn(projectId: string | undefined) { + return accountService.value.deleteSettings(projectId); + }, + onSettled() { + client.invalidateQueries({ + queryKey: ['settings'], + }); + }, + }); +} diff --git a/web/composables/useDistanceUnit.ts b/web/composables/useDistanceUnit.ts deleted file mode 100644 index c1892a8..0000000 --- a/web/composables/useDistanceUnit.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default createGlobalState(() => - useLocalStorage<'in' | 'm' | 'mm'>('@cutlist/distance-unit', 'in'), -); diff --git a/web/composables/useDocumentQuery.ts b/web/composables/useDocumentQuery.ts index 40539eb..6ea85a2 100644 --- a/web/composables/useDocumentQuery.ts +++ b/web/composables/useDocumentQuery.ts @@ -1,16 +1,15 @@ import { useQuery } from '@tanstack/vue-query'; import useOnshapeUrl from './useOnshapeUrl'; -export default function () { +export default function (url: MaybeRefOrGetter) { const onshape = useOnshapeLoader(); - - const url = useAssemblyUrl(); - const onshapeUrl = useOnshapeUrl(url); + const onshapeUrl = useOnshapeUrl(() => toValue(url) ?? ''); return useQuery({ queryKey: ['onshape', 'document', computed(() => toValue(url))], queryFn: async () => { - if (onshapeUrl.value == null) return undefined; + if (onshapeUrl.value == null) + throw Error('Invalid onshape assembly URL: ' + toValue(url)); return await onshape.getDocument(onshapeUrl.value.did); }, }); diff --git a/web/composables/useExtraSpace.ts b/web/composables/useExtraSpace.ts deleted file mode 100644 index 8e93a84..0000000 --- a/web/composables/useExtraSpace.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Distance } from '@aklinker1/cutlist'; - -/** - * Returns the extra space in standard units based off the settings. - */ -export default function () { - const extraSpace = useExtraSpaceSetting(); - const unit = useDistanceUnit(); - return computed(() => new Distance(extraSpace.value + unit.value).m); -} diff --git a/web/composables/useExtraSpaceSetting.ts b/web/composables/useExtraSpaceSetting.ts deleted file mode 100644 index 14548a6..0000000 --- a/web/composables/useExtraSpaceSetting.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stores the preference from the settings page directly. Unless you're on the - * settings page, you should probably use `useExtraSpace` instead for the - * standardized value. - */ -export default createGlobalState(() => - useLocalStorage('@cutlist/extra-space', '0'), -); diff --git a/web/composables/useFormatDistance.ts b/web/composables/useFormatDistance.ts index 257f3aa..2a59b86 100644 --- a/web/composables/useFormatDistance.ts +++ b/web/composables/useFormatDistance.ts @@ -1,16 +1,16 @@ import { Distance, toFraction } from '@aklinker1/cutlist'; export default function () { - const unit = useDistanceUnit(); + const { distanceUnit } = useProjectSettings(); return (m: number | undefined | null) => { - if (m == null) return; + if (m == null || toValue(distanceUnit) == null) return; const distance = new Distance(m); - if (toValue(unit) === 'in') { + if (toValue(distanceUnit) === 'in') { return `${toFraction(distance.in)}"`; } - if (toValue(unit) === 'mm') { + if (toValue(distanceUnit) === 'mm') { return `${distance.mm}mm`; } return `${distance.m.toFixed(3)}m`; diff --git a/web/composables/useOnshapeUrl.ts b/web/composables/useOnshapeUrl.ts index 103255e..6ff0d67 100644 --- a/web/composables/useOnshapeUrl.ts +++ b/web/composables/useOnshapeUrl.ts @@ -1,6 +1,6 @@ import { parseOnshapeUrl } from '@aklinker1/cutlist/onshape'; -export default function (url: MaybeRefOrGetter) { +export default function (url: MaybeRefOrGetter) { return computed(() => { const u = toValue(url); if (u == null) return undefined; diff --git a/web/composables/useOptimizeFor.ts b/web/composables/useOptimizeFor.ts deleted file mode 100644 index c3f2212..0000000 --- a/web/composables/useOptimizeFor.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Returns the parsed optimize config based on the raw settings. - */ -export default function () { - const optimize = useOptimizeForSetting(); - return computed(() => (optimize.value === 'Cuts' ? 'cuts' : 'space')); -} diff --git a/web/composables/useOptimizeForSetting.ts b/web/composables/useOptimizeForSetting.ts deleted file mode 100644 index 1453502..0000000 --- a/web/composables/useOptimizeForSetting.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Stores the preference from the settings page directly. Unless you're on the - * settings page, you should probably use `useOptimze` instead for the - * standardized value. - */ -export default createGlobalState(() => - useLocalStorage('@cutlist/optimize', 'Cuts'), -); diff --git a/web/composables/useParseStock.ts b/web/composables/useParseStock.ts new file mode 100644 index 0000000..a52e141 --- /dev/null +++ b/web/composables/useParseStock.ts @@ -0,0 +1,9 @@ +import { StockMatrix } from '@aklinker1/cutlist'; +import { z } from 'zod'; +import YAML from 'js-yaml'; + +export default function () { + return (stock: string): StockMatrix[] => { + return z.array(StockMatrix).parse(YAML.load(stock)); + }; +} diff --git a/web/composables/useProjectId.ts b/web/composables/useProjectId.ts new file mode 100644 index 0000000..9f51751 --- /dev/null +++ b/web/composables/useProjectId.ts @@ -0,0 +1,4 @@ +export default function () { + const project = useProject(); + return computed(() => project.value?.id); +} diff --git a/web/composables/useProjectListQuery.ts b/web/composables/useProjectListQuery.ts new file mode 100644 index 0000000..475c5ce --- /dev/null +++ b/web/composables/useProjectListQuery.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/vue-query'; + +export default function () { + const account = useAccountService(); + const accountId = useAccountServiceId(); + return useQuery({ + queryKey: ['projects', accountId], + queryFn: () => account.value.listProjects(), + initialData: [], + refetchOnMount: true, + }); +} diff --git a/web/composables/useProjectSettings.ts b/web/composables/useProjectSettings.ts new file mode 100644 index 0000000..5bbf943 --- /dev/null +++ b/web/composables/useProjectSettings.ts @@ -0,0 +1,68 @@ +export default function () { + const store = useProjectSettingsStore(); + const projectId = useProjectId(); + + const defineSettingValue = (key: T) => + computed({ + get() { + return store.value[String(toValue(projectId))]?.[key]; + }, + set(value) { + store.value[String(toValue(projectId))] ??= {}; + store.value[String(toValue(projectId))][key] = value; + }, + }); + + const bladeWidth = defineSettingValue('bladeWidth'); + const distanceUnit = defineSettingValue('distanceUnit'); + const extraSpace = defineSettingValue('extraSpace'); + const optimize = defineSettingValue('optimize'); + const showPartNumbers = defineSettingValue('showPartNumbers'); + const stock = defineSettingValue('stock'); + + const { data: settings, isLoading } = useSettingsQuery(projectId); + watch(settings, () => { + resetSettings(); + resetStock(); + }); + + const changes = computed(() => { + const changes: Partial = {}; + if (settings.value?.bladeWidth !== bladeWidth.value) + changes.bladeWidth = bladeWidth.value; + if (settings.value?.distanceUnit !== distanceUnit.value) + changes.distanceUnit = distanceUnit.value; + if (settings.value?.extraSpace !== extraSpace.value) + changes.extraSpace = extraSpace.value; + if (settings.value?.optimize !== optimize.value) + changes.optimize = optimize.value; + if (settings.value?.showPartNumbers !== showPartNumbers.value) + changes.showPartNumbers = showPartNumbers.value; + if (settings.value?.stock !== stock.value) changes.stock = stock.value; + return changes; + }); + + const resetSettings = () => { + bladeWidth.value = settings.value?.bladeWidth; + distanceUnit.value = settings.value?.distanceUnit; + extraSpace.value = settings.value?.extraSpace; + optimize.value = settings.value?.optimize; + showPartNumbers.value = settings.value?.showPartNumbers; + }; + const resetStock = () => { + stock.value = settings.value?.stock; + }; + + return { + bladeWidth, + distanceUnit, + extraSpace, + optimize, + showPartNumbers, + stock, + resetSettings, + resetStock, + isLoading, + changes, + }; +} diff --git a/web/composables/useProjectSettingsStore.ts b/web/composables/useProjectSettingsStore.ts new file mode 100644 index 0000000..00d22c0 --- /dev/null +++ b/web/composables/useProjectSettingsStore.ts @@ -0,0 +1,3 @@ +export default createGlobalState(() => + ref>>({}), +); diff --git a/web/composables/useProjectTabMap.ts b/web/composables/useProjectTabMap.ts index 815fe12..71eed38 100644 --- a/web/composables/useProjectTabMap.ts +++ b/web/composables/useProjectTabMap.ts @@ -2,4 +2,4 @@ export default createGlobalState(() => useSessionStorage>('@cutlist/tab-map', {}), ); -export type Tab = 'bom' | 'stock' | 'settings' | 'warnings'; +export type Tab = 'bom' | 'boards' | 'settings' | 'warnings'; diff --git a/web/composables/useSetSettingsMutation.ts b/web/composables/useSetSettingsMutation.ts new file mode 100644 index 0000000..ce72c7b --- /dev/null +++ b/web/composables/useSetSettingsMutation.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/vue-query'; +import type { AccountSettings } from '~/utils'; + +export default function () { + const accountService = useAccountService(); + const client = useQueryClient(); + + return useMutation({ + mutationFn({ + projectId, + changes, + }: { + projectId: string | undefined; + changes: Partial; + }) { + console.log('SAVING...', { changes }); + return accountService.value.setSettings(projectId, changes); + }, + onSettled() { + client.invalidateQueries({ + queryKey: ['settings'], + }); + }, + }); +} diff --git a/web/composables/useSettingsQuery.ts b/web/composables/useSettingsQuery.ts new file mode 100644 index 0000000..44e65b5 --- /dev/null +++ b/web/composables/useSettingsQuery.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/vue-query'; + +export default function (projectId: Ref) { + const account = useAccountService(); + const accountId = useAccountServiceId(); + return useQuery({ + queryKey: ['settings', accountId, projectId], + queryFn: () => account.value.getSettings(projectId.value), + }); +} diff --git a/web/composables/useShowPartNumbers.ts b/web/composables/useShowPartNumbers.ts deleted file mode 100644 index aa323bf..0000000 --- a/web/composables/useShowPartNumbers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default createGlobalState(() => - useLocalStorage('@cutlist/use-part-numbers', true), -); diff --git a/web/composables/useStock.ts b/web/composables/useStock.ts deleted file mode 100644 index 957d1c9..0000000 --- a/web/composables/useStock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { StockMatrix } from '@aklinker1/cutlist'; - -export default createGlobalState(() => - useLocalStorage('@cutlist/stock', [ - { - material: 'Oak, Red', - length: ['3ft', '4ft', '6ft', '8ft'], - thickness: ['0.75in'], - width: ['1.5in', '2.5in', '3.5in', '5.5in', '7.5in'], - }, - ]), -); diff --git a/web/layouts/default.vue b/web/layouts/default.vue index 305efe9..0da34d3 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -18,23 +18,30 @@ const closeTab = useCloseTab(); - + - + - + + + + + + + + - + - + diff --git a/web/package.json b/web/package.json index b5ed18b..a71c324 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "@types/js-yaml": "^4.0.9", "@vueuse/core": "^10.9.0", "@vueuse/nuxt": "^10.9.0", + "firebase": "^10.11.0", "nuxt": "^3.11.0", "typescript": "^5.0.0", "vue": "^3.4.21", diff --git a/web/pages/account.vue b/web/pages/account.vue new file mode 100644 index 0000000..d86f627 --- /dev/null +++ b/web/pages/account.vue @@ -0,0 +1,63 @@ + + + + + Account + + Login to share settings and projects between devices. + + + + + Logged in as: {{ currentUser.displayName }} ({{ currentUser.email }}) + + Log out + + + diff --git a/web/pages/index.vue b/web/pages/index.vue index fc95a3b..a230553 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -2,15 +2,6 @@ import { version } from '~~/package.json'; const createNewProject = useCreateNewProject(); - -const projects = useProjects(); -const filter = ref(''); -const filteredProjects = useArrayFilter(projects, (p) => - p.name.toLowerCase().includes(filter.value.toLowerCase()), -); - -const openProject = useOpenProject(); -const deleteProject = useDeleteProject(); @@ -28,25 +19,7 @@ const deleteProject = useDeleteProject(); > - - - - - - - No projects, - add one - + User Manual - + diff --git a/web/pages/p/[id].vue b/web/pages/p/[id].vue index 1dde661..ca86a9d 100644 --- a/web/pages/p/[id].vue +++ b/web/pages/p/[id].vue @@ -5,7 +5,7 @@ const isExpanded = useIsExpanded(); - diff --git a/web/utils/accounts/AccountService.ts b/web/utils/accounts/AccountService.ts new file mode 100644 index 0000000..6d78614 --- /dev/null +++ b/web/utils/accounts/AccountService.ts @@ -0,0 +1,49 @@ +import YAML from 'js-yaml'; +import type { Project } from '../projects'; +import type { StockMatrix } from '@aklinker1/cutlist'; + +export interface AccountService { + id: string; + getSettings(projectId: string | undefined): Promise; + setSettings( + projectId: string | undefined, + changes: Partial, + ): Promise; + deleteSettings(projectId: string | undefined): Promise; + listProjects(): Promise; + saveProject(newProject: Project): Promise; + removeProject(id: string): Promise; +} + +export interface AccountSettings { + bladeWidth: number; + distanceUnit: 'in' | 'm' | 'mm'; + extraSpace: number; + optimize: 'Cuts' | 'Space'; + showPartNumbers: boolean; + stock: string; +} + +const DEFAULT_STOCK: StockMatrix[] = [ + { + material: 'Oak, Red', + length: ['3ft', '4ft', '6ft', '8ft'], + thickness: ['0.5in', '0.75in', '1in'], + width: ['1.5in', '2.5in', '3.5in', '5.5in', '7.5in'], + }, + { + material: 'Plywood', + width: ['4ft'], + length: ['8ft'], + thickness: ['0.25in', '0.5in', '0.75in'], + }, +]; + +export const DEFAULT_SETTINGS: AccountSettings = { + bladeWidth: 0.125, + distanceUnit: 'in', + extraSpace: 0, + optimize: 'Cuts', + showPartNumbers: true, + stock: YAML.dump(DEFAULT_STOCK, { indent: 2, flowLevel: 2 }), +}; diff --git a/web/utils/accounts/FirebaseAccountService.ts b/web/utils/accounts/FirebaseAccountService.ts new file mode 100644 index 0000000..bbd8d2f --- /dev/null +++ b/web/utils/accounts/FirebaseAccountService.ts @@ -0,0 +1,51 @@ +import { + getDoc, + setDoc, + getDocs, + query, + deleteDoc, + collection, + doc, +} from 'firebase/firestore'; +import { firebaseAuth, usersRef } from '../firebase'; +import { DEFAULT_SETTINGS } from './AccountService'; + +export function createFirebaseAccountService(): AccountService { + const getUid = () => { + if (firebaseAuth.currentUser == null) throw Error('Not logged in'); + return firebaseAuth.currentUser.uid; + }; + + const settingsDoc = (projectId: string | undefined) => + doc(usersRef, getUid(), 'settings', projectId ?? 'default'); + const projectsRef = () => collection(usersRef, getUid(), 'projects'); + const projectDoc = (id: string) => doc(usersRef, getUid(), 'projects', id); + + return { + id: 'firebase', + async getSettings(projectId) { + const res = await getDoc(settingsDoc(projectId)); + return { + ...DEFAULT_SETTINGS, + ...(res.exists() ? res.data() : {}), + }; + }, + async setSettings(projectId, changes) { + await setDoc(settingsDoc(projectId), changes, { merge: true }); + }, + async deleteSettings(projectId) { + await deleteDoc(settingsDoc(projectId)); + }, + async listProjects() { + const q = query(projectsRef()); + const res = await getDocs(q); + return res.docs.map((doc) => doc.data()) as Project[]; + }, + async saveProject(project) { + await setDoc(projectDoc(project.id), project); + }, + async removeProject(projectId) { + await deleteDoc(projectDoc(projectId)); + }, + }; +} diff --git a/web/utils/accounts/LocalAccountService.ts b/web/utils/accounts/LocalAccountService.ts new file mode 100644 index 0000000..ba5e304 --- /dev/null +++ b/web/utils/accounts/LocalAccountService.ts @@ -0,0 +1,112 @@ +import type { Project } from '../projects'; +import { + DEFAULT_SETTINGS, + type AccountService, + type AccountSettings, +} from './AccountService'; + +export function createLocalAccountService(): AccountService { + const _settingsStorageKeys: Record = { + bladeWidth: '@cutlist/blade-width', + distanceUnit: '@cutlist/distance-unit', + extraSpace: '@cutlist/extra-space', + optimize: '@cutlist/optimize', + showPartNumbers: '@cutlist/use-part-numbers', + stock: '@cutlist/stock', + }; + const projectsStorageKey = '@cutlist/projects'; + + const getKey = ( + key: T, + projectId: string | undefined, + ) => { + if (projectId == null) return _settingsStorageKeys[key]; + return `${_settingsStorageKeys[key]}?id=${projectId}`; + }; + + const parseNumber = (str: string | null): number | undefined => { + const v = Number(str ?? undefined); + if (isNaN(v)) return undefined; + return v; + }; + const parseBoolean = (str: string | null): boolean | undefined => { + if (str == null) return undefined; + return str === 'true'; + }; + + const getSettings = (projectId: string | undefined): AccountSettings => { + const settings: Partial = {}; + + const bladeWidth = parseNumber( + localStorage.getItem(getKey('bladeWidth', projectId)), + ); + if (bladeWidth != null) settings.bladeWidth = bladeWidth; + + const extraSpace = parseNumber( + localStorage.getItem(getKey('extraSpace', projectId)), + ); + if (extraSpace != null) settings.extraSpace = extraSpace; + + const optimize = localStorage.getItem(getKey('optimize', projectId)); + if (optimize != null) + settings.optimize = optimize as AccountSettings['optimize']; + + const showPartNumbers = parseBoolean( + localStorage.getItem(getKey('showPartNumbers', projectId)), + ); + if (showPartNumbers != null) settings.showPartNumbers = showPartNumbers; + + const distanceUnit = localStorage.getItem( + getKey('distanceUnit', projectId), + ); + if (distanceUnit != null) + settings.distanceUnit = distanceUnit as AccountSettings['distanceUnit']; + + return { + ...DEFAULT_SETTINGS, + ...settings, + }; + }; + + const listProjects = (): Project[] => { + const str = localStorage.getItem(projectsStorageKey); + return str ? JSON.parse(str) : []; + }; + const saveProjects = (projects: Project[]) => { + localStorage.setItem(projectsStorageKey, JSON.stringify(projects)); + }; + + return { + id: 'local', + async getSettings(projectId) { + return getSettings(projectId); + }, + async setSettings(projectId, changes) { + Object.entries(changes).forEach(([key, value]) => { + const localStorageKey = getKey(key as keyof AccountSettings, projectId); + if (!localStorageKey) return; + + localStorage.setItem(localStorageKey, String(value)); + }); + }, + async deleteSettings(projectId) { + Object.keys(_settingsStorageKeys).forEach((key) => + localStorage.removeItem( + getKey(key as keyof AccountSettings, projectId), + ), + ); + }, + async listProjects() { + return listProjects(); + }, + async removeProject(id) { + const newProjects = listProjects().filter((p) => p.id !== id); + saveProjects(newProjects); + }, + async saveProject(newProject) { + const newProjects = listProjects(); + newProjects.push(newProject); + saveProjects(newProjects); + }, + }; +} diff --git a/web/utils/accounts/index.ts b/web/utils/accounts/index.ts new file mode 100644 index 0000000..da684a5 --- /dev/null +++ b/web/utils/accounts/index.ts @@ -0,0 +1,3 @@ +export * from './AccountService'; +export * from './FirebaseAccountService'; +export * from './LocalAccountService'; diff --git a/web/utils/firebase.ts b/web/utils/firebase.ts new file mode 100644 index 0000000..53e6800 --- /dev/null +++ b/web/utils/firebase.ts @@ -0,0 +1,17 @@ +import { initializeApp } from 'firebase/app'; +import { getFirestore, collection, doc } from 'firebase/firestore'; +import { getAuth } from 'firebase/auth'; + +export const firebaseApp = initializeApp({ + apiKey: 'AIzaSyBKkmNsLYzz12hNoGMFP3eaHCtxPJCsIi0', + authDomain: 'cutlist-17450.firebaseapp.com', + projectId: 'cutlist-17450', + storageBucket: 'cutlist-17450.appspot.com', + messagingSenderId: '149400144857', + appId: '1:149400144857:web:fa6f4a90ec177fd90ce7c8', +}); + +export const db = getFirestore(firebaseApp); +export const usersRef = collection(db, 'users'); + +export const firebaseAuth = getAuth(firebaseApp); diff --git a/web/utils/index.ts b/web/utils/index.ts new file mode 100644 index 0000000..9a925f3 --- /dev/null +++ b/web/utils/index.ts @@ -0,0 +1 @@ +export * from './accounts';
+ by {{ doc.owner.name }} • + + {{ doc.defaultWorkspace.name }} Revision + + +
- Settings are saved when returning to the website. -
- {{ error }} -
+ {{ err }} +
Login to share settings and projects between devices.
+ Logged in as: {{ currentUser.displayName }} ({{ currentUser.email }}) +