From 8073003460c086ffbb4ee968707a5759a487668d Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Sun, 8 Feb 2026 13:57:46 +0100 Subject: [PATCH] refactor: modularize directus.js into 8 focused submodules with backward-compatible facade --- js/services/directus.js | 921 +++----------------------- js/services/directus/auth.js | 50 ++ js/services/directus/categories.js | 55 ++ js/services/directus/client.js | 255 +++++++ js/services/directus/conversations.js | 93 +++ js/services/directus/files.js | 48 ++ js/services/directus/listings.js | 163 +++++ js/services/directus/locations.js | 40 ++ js/services/directus/notifications.js | 37 ++ service-worker.js | 10 +- 10 files changed, 829 insertions(+), 843 deletions(-) create mode 100644 js/services/directus/auth.js create mode 100644 js/services/directus/categories.js create mode 100644 js/services/directus/client.js create mode 100644 js/services/directus/conversations.js create mode 100644 js/services/directus/files.js create mode 100644 js/services/directus/listings.js create mode 100644 js/services/directus/locations.js create mode 100644 js/services/directus/notifications.js diff --git a/js/services/directus.js b/js/services/directus.js index 12479e5..c75f8a8 100644 --- a/js/services/directus.js +++ b/js/services/directus.js @@ -1,850 +1,87 @@ /** - * Directus API Service for dgray.io - * Connects to https://api.dgray.io/ + * Directus API Service for dgray.io — Facade + * Re-exports modular sub-services as a single backward-compatible singleton. * @module services/directus */ -/** @type {string} */ -const DIRECTUS_URL = 'https://api.dgray.io' - -/** - * @typedef {Object} AuthTokens - * @property {string} access_token - JWT access token - * @property {string} refresh_token - Refresh token for session renewal - * @property {number} expires - Token expiry in seconds - */ - -/** - * @typedef {Object} Listing - * @property {string} id - UUID - * @property {string} title - Listing title - * @property {string} [description] - Listing description - * @property {number} price - Price value - * @property {string} currency - Currency code (EUR, USD, CHF, XMR) - * @property {string} status - Status (draft, published, sold, expired) - * @property {Object} [location] - Location object - * @property {Array} [images] - Array of image objects - */ - -/** - * @typedef {Object} Category - * @property {string} id - UUID - * @property {string} slug - URL-safe slug - * @property {string} [parent] - Parent category ID - * @property {Array} [translations] - Category translations - */ - -/** - * Main Directus API client - * @class - */ -class DirectusService { - constructor() { - this.baseUrl = DIRECTUS_URL - this.accessToken = null - this.refreshToken = null - this.tokenExpiry = null - this.refreshTimeout = null - this._refreshPromise = null - - this.loadTokens() - this.setupVisibilityRefresh() - } - - // ==================== Token Management ==================== - - loadTokens() { - const stored = localStorage.getItem('dgray_auth') - if (stored) { - try { - const { accessToken, refreshToken, expiry } = JSON.parse(stored) - this.accessToken = accessToken - this.refreshToken = refreshToken - this.tokenExpiry = expiry - this.scheduleTokenRefresh() - } catch (e) { - this.clearTokens() - } - } - } - - saveTokens(accessToken, refreshToken, expiresIn) { - this.accessToken = accessToken - this.refreshToken = refreshToken - this.tokenExpiry = Date.now() + (expiresIn * 1000) - - localStorage.setItem('dgray_auth', JSON.stringify({ - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiry: this.tokenExpiry - })) - - this.scheduleTokenRefresh() - } - - clearTokens() { - this.accessToken = null - this.refreshToken = null - this.tokenExpiry = null - localStorage.removeItem('dgray_auth') - - if (this.refreshTimeout) { - clearTimeout(this.refreshTimeout) - this.refreshTimeout = null - } - } - - scheduleTokenRefresh() { - if (this.refreshTimeout) { - clearTimeout(this.refreshTimeout) - } - - if (!this.tokenExpiry || !this.refreshToken) return - - // Refresh 1 minute before expiry - const refreshIn = this.tokenExpiry - Date.now() - 60000 - - if (refreshIn > 0) { - this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn) - } else if (this.refreshToken) { - this.refreshSession() - } - } - - setupVisibilityRefresh() { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && this.refreshToken) { - const timeLeft = this.tokenExpiry - Date.now() - if (timeLeft < 120000) { - this.refreshSession() - } - this.scheduleTokenRefresh() - } - }) - } - - isAuthenticated() { - return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry) - } - - // ==================== HTTP Methods ==================== - - /** - * Make an HTTP request to the Directus API - * @param {string} endpoint - API endpoint path - * @param {RequestInit} [options={}] - Fetch options - * @returns {Promise} Response data or null for 204 - * @throws {DirectusError} On request failure - */ - async request(endpoint, options = {}, _retryCount = 0) { - const url = `${this.baseUrl}${endpoint}` - - const headers = { - 'Content-Type': 'application/json', - ...options.headers - } - - if (this.accessToken) { - headers['Authorization'] = `Bearer ${this.accessToken}` - } - - try { - const response = await fetch(url, { - ...options, - headers - }) - - if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) { - if (!this._refreshPromise) { - this._refreshPromise = this.refreshSession().finally(() => { - this._refreshPromise = null - }) - } - const refreshed = await this._refreshPromise - if (!refreshed) { - this.clearTokens() - } - return this.request(endpoint, options, _retryCount + 1) - } - - if (response.status === 429 && _retryCount < 3) { - const retryAfterHeader = response.headers.get('Retry-After') || '1' - let waitMs - if (parseInt(retryAfterHeader, 10) > 100) { - waitMs = parseInt(retryAfterHeader, 10) - } else { - waitMs = parseInt(retryAfterHeader, 10) * 1000 - } - await new Promise(r => setTimeout(r, waitMs)) - return this.request(endpoint, options, _retryCount + 1) - } - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error) - } - - if (response.status === 204) { - return null - } - - return await response.json() - } catch (error) { - if (error instanceof DirectusError) throw error - throw new DirectusError(0, 'Network error', { originalError: error }) - } - } - - /** - * GET request with query parameters - * @param {string} endpoint - API endpoint - * @param {Object} [params={}] - Query parameters - * @returns {Promise} Response data - */ - async get(endpoint, params = {}) { - const queryString = this.buildQueryString(params) - const url = queryString ? `${endpoint}?${queryString}` : endpoint - return this.request(url, { method: 'GET' }) - } - - /** - * POST request with JSON body - * @param {string} endpoint - API endpoint - * @param {Object} data - Request body - * @returns {Promise} Response data - */ - async post(endpoint, data) { - return this.request(endpoint, { - method: 'POST', - body: JSON.stringify(data) - }) - } - - /** - * PATCH request with JSON body - * @param {string} endpoint - API endpoint - * @param {Object} data - Request body - * @returns {Promise} Response data - */ - async patch(endpoint, data) { - return this.request(endpoint, { - method: 'PATCH', - body: JSON.stringify(data) - }) - } - - async delete(endpoint) { - return this.request(endpoint, { method: 'DELETE' }) - } - - buildQueryString(params) { - const searchParams = new URLSearchParams() - - for (const [key, value] of Object.entries(params)) { - if (value === undefined || value === null) continue - - if (key === 'sort' && Array.isArray(value)) { - searchParams.set(key, value.join(',')) - } else if (key === 'fields' && Array.isArray(value)) { - searchParams.set(key, value.join(',')) - } else if (typeof value === 'object') { - searchParams.set(key, JSON.stringify(value)) - } else { - searchParams.set(key, value) - } - } - - return searchParams.toString() - } - - // ==================== Authentication ==================== - - async login(email, password) { - const response = await this.post('/auth/login', { email, password }) - - if (response.data) { - this.saveTokens( - response.data.access_token, - response.data.refresh_token, - response.data.expires - ) - } - - return response.data - } - - async logout() { - if (this.refreshToken) { - try { - await this.post('/auth/logout', { refresh_token: this.refreshToken }) - } catch (e) { - // Ignore errors - tokens will be cleared anyway - } - } - this.clearTokens() - } - - async refreshSession(_retryCount = 0) { - if (!this.refreshToken) return false - - try { - const response = await fetch(`${this.baseUrl}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - refresh_token: this.refreshToken, - mode: 'json' - }) - }) - - if (!response.ok) throw new Error('Refresh failed') - - const result = await response.json() - - if (result.data) { - this.saveTokens( - result.data.access_token, - result.data.refresh_token, - result.data.expires - ) - return true - } - } catch (e) { - if (_retryCount < 2) { - await new Promise(r => setTimeout(r, 2000)) - return this.refreshSession(_retryCount + 1) - } - this.clearTokens() - } - - return false - } - - async register(email, password) { - // Public registration (no verification required) - return this.post('/users/register', { email, password }) - } - - async requestPasswordReset(email) { - return this.post('/auth/password/request', { email }) - } - - async resetPassword(token, password) { - return this.post('/auth/password/reset', { token, password }) - } - - async getCurrentUser() { - const response = await this.get('/users/me', { - fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status', 'preferred_currency', 'preferred_locale'] - }) - return response.data - } - - async updateCurrentUser(data) { - const response = await this.patch('/users/me', data) - return response.data - } - - // ==================== Listings (Anzeigen) ==================== - - /** - * Get listings with filters and pagination - * @param {Object} [options={}] - Query options - * @param {number} [options.limit=20] - Max items to return - * @param {number} [options.page=1] - Page number - * @param {string} [options.category] - Filter by category slug - * @param {string} [options.search] - Search query - * @returns {Promise<{items: Listing[], total: number}>} Paginated listings - */ - async getListings(options = {}) { - const params = { - fields: options.fields || [ - 'id', - 'status', - 'title', - 'slug', - 'price', - 'currency', - 'condition', - 'expires_at', - 'date_created', - 'user_created', - 'images.directus_files_id.id', - 'category.id', - 'category.name', - 'category.slug', - 'category.icon', - 'location.id', - 'location.name', - 'location.postal_code', - 'location.country', - 'location.latitude', - 'location.longitude' - ], - filter: options.filter || { - status: { _eq: 'published' }, - _or: [ - { expires_at: { _null: true } }, - { expires_at: { _gt: '$NOW' } } - ] - }, - sort: options.sort || ['-date_created'], - limit: options.limit || 20, - page: options.page || 1 - } - - if (options.search) { - params.search = options.search - } - - const response = await this.get('/items/listings', params) - return { - items: response.data, - meta: response.meta - } - } - - /** - * Get a single listing by ID - * @param {string} id - Listing UUID - * @returns {Promise} Listing object or null - */ - async getListing(id) { - const response = await this.get(`/items/listings/${id}`, { - fields: [ - 'id', - 'status', - 'title', - 'slug', - 'description', - 'price', - 'currency', - 'price_mode', - 'price_type', - 'condition', - 'shipping', - 'shipping_cost', - 'views', - 'paid_at', - 'payment_status', - 'expires_at', - 'date_created', - 'user_created', - 'images.directus_files_id.id', - 'category.id', - 'category.name', - 'category.slug', - 'category.translations.*', - 'location.id', - 'location.name', - 'location.postal_code', - 'location.country' - ] - }) - return response.data - } - - /** - * Create a new listing - * @param {Object} data - Listing data - * @returns {Promise} Created listing - */ - async createListing(data) { - const response = await this.post('/items/listings', data) - return response?.data || response - } - - async updateListing(id, data) { - const response = await this.patch(`/items/listings/${id}`, data) - return response.data - } - - async incrementListingViews(id) { - // Check if user already viewed this listing (session-based) - const viewedKey = `dgray_viewed_${id}` - if (sessionStorage.getItem(viewedKey)) { - return null - } - - try { - // Get current views - const listing = await this.get(`/items/listings/${id}`, { - fields: ['views'] - }) - const currentViews = listing.data?.views || 0 - const newViews = currentViews + 1 - - // Increment views - await this.patch(`/items/listings/${id}`, { - views: newViews - }) - - // Mark as viewed for this session - sessionStorage.setItem(viewedKey, 'true') - return newViews - } catch (error) { - console.error('Failed to increment views:', error) - return null - } - } - - async deleteListing(id) { - return this.delete(`/items/listings/${id}`) - } - - async getMyListings() { - const response = await this.get('/items/listings', { - fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.name'], - filter: { user_created: { _eq: '$CURRENT_USER' } }, - sort: ['-date_created'] - }) - return response.data || [] - } - - async searchListings(query, options = {}) { - return this.getListings({ - search: query, - ...options - }) - } - - async getListingsByCategory(categoryId, options = {}) { - return this.getListings({ - filter: { - status: { _eq: 'published' }, - category: { _eq: categoryId } - }, - ...options - }) - } - - async getListingsByLocation(locationId, options = {}) { - return this.getListings({ - filter: { - status: { _eq: 'published' }, - location: { _eq: locationId } - }, - ...options - }) - } - - // ==================== Categories (Kategorien) ==================== - - async getCategories() { - const response = await this.get('/items/categories', { - fields: ['*', 'translations.*'], - filter: { status: { _eq: 'published' } }, - sort: ['sort', 'name'], - limit: -1 - }) - return response.data || [] - } - - async getCategory(idOrSlug) { - const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug) - - if (isUuid) { - const response = await this.get(`/items/categories/${idOrSlug}`, { - fields: ['*', 'translations.*', 'parent.*'] - }) - return response.data - } - - const response = await this.get('/items/categories', { - fields: ['*', 'translations.*', 'parent.*'], - filter: { slug: { _eq: idOrSlug } }, - limit: 1 - }) - return response.data?.[0] || null - } - - async getCategoryTree() { - const categories = await this.getCategories() - return this.buildCategoryTree(categories) - } - - buildCategoryTree(categories, parentId = null) { - return categories - .filter(cat => (cat.parent?.id || cat.parent) === parentId) - .map(cat => ({ - ...cat, - children: this.buildCategoryTree(categories, cat.id) - })) - } - - async getSubcategories(parentId) { - const response = await this.get('/items/categories', { - fields: ['*', 'translations.*'], - filter: { - status: { _eq: 'published' }, - parent: { _eq: parentId } - }, - sort: ['sort', 'name'] - }) - return response.data - } - - // ==================== Locations (Standorte) ==================== - - async getLocations(options = {}) { - const response = await this.get('/items/locations', { - fields: options.fields || ['*'], - filter: options.filter || {}, - sort: options.sort || ['name'], - limit: options.limit || -1 - }) - return response.data - } - - async getLocation(id) { - const response = await this.get(`/items/locations/${id}`) - return response.data - } - - async searchLocations(query) { - const response = await this.get('/items/locations', { - search: query, - limit: 20 - }) - return response.data - } - - async getLocationsByRegion(region) { - const response = await this.get('/items/locations', { - filter: { region: { _eq: region } }, - sort: ['name'] - }) - return response.data - } - - async getLocationsByCountry(country) { - const response = await this.get('/items/locations', { - filter: { country: { _eq: country } }, - sort: ['region', 'name'] - }) - return response.data - } - - // ==================== Conversations (Zero-Knowledge Chat) ==================== - - async getConversations(participantHash) { - const response = await this.get('/items/conversations', { - fields: [ - '*', - 'listing_id.id', - 'listing_id.title', - 'listing_id.status', - 'listing_id.images.directus_files_id.id' - ], - filter: { - _or: [ - { participant_hash_1: { _eq: participantHash } }, - { participant_hash_2: { _eq: participantHash } } - ] - }, - sort: ['-date_updated'] - }) - return response.data - } - - async getConversation(id) { - const response = await this.get(`/items/conversations/${id}`, { - fields: [ - '*', - 'listing_id.*', - 'listing_id.images.directus_files_id.*' - ] - }) - return response.data - } - - async getConversationMessages(conversationId) { - const response = await this.get('/items/messages', { - fields: ['*'], - filter: { conversation: { _eq: conversationId } }, - sort: ['date_created'] - }) - return response.data - } - - async sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') { - const response = await this.post('/items/messages', { - conversation: conversationId, - sender_hash: senderHash, - content_encrypted: encryptedContent, - nonce: nonce, - type: type - }) - return response.data - } - - async startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) { - const response = await this.post('/items/conversations', { - listing_id: listingId, - participant_hash_1: participantHash1, - participant_hash_2: participantHash2, - public_key_1: publicKey1, - public_key_2: publicKey2, - status: 'active' - }) - return response.data - } - - async findConversation(listingId, participantHash1, participantHash2) { - const response = await this.get('/items/conversations', { - filter: { - listing_id: { _eq: listingId }, - _or: [ - { - _and: [ - { participant_hash_1: { _eq: participantHash1 } }, - { participant_hash_2: { _eq: participantHash2 } } - ] - }, - { - _and: [ - { participant_hash_1: { _eq: participantHash2 } }, - { participant_hash_2: { _eq: participantHash1 } } - ] - } - ] - }, - limit: 1 - }) - return response.data?.[0] || null - } - - async updateConversationStatus(id, status) { - const response = await this.patch(`/items/conversations/${id}`, { status }) - return response.data - } - - // ==================== Files (Dateien/Bilder) ==================== - - /** - * Upload a file to Directus - * @param {File} file - File to upload - * @param {Object} [options={}] - Upload options - * @param {string} [options.folder] - Target folder ID - * @returns {Promise} Uploaded file data - */ - async uploadFile(file, options = {}) { - const formData = new FormData() - formData.append('file', file) - - if (options.folder) { - formData.append('folder', options.folder) - } - - const response = await fetch(`${this.baseUrl}/files`, { - method: 'POST', - headers: this.accessToken ? { 'Authorization': `Bearer ${this.accessToken}` } : {}, - body: formData - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({})) - throw new DirectusError(response.status, 'Upload failed', error) - } - - const result = await response.json() - return result.data - } - - async uploadMultipleFiles(files, options = {}) { - const uploads = Array.from(files).map(file => this.uploadFile(file, options)) - return Promise.all(uploads) - } - - /** - * Get URL for a file with optional transformations - * @param {string} fileId - File UUID - * @param {Object} [options={}] - Transform options - * @param {number} [options.width] - Resize width - * @param {number} [options.height] - Resize height - * @param {string} [options.fit] - Fit mode (cover, contain, etc.) - * @returns {string|null} File URL or null - */ - getFileUrl(fileId, options = {}) { - if (!fileId) return null - - const params = new URLSearchParams() - - if (options.width) params.set('width', options.width) - if (options.height) params.set('height', options.height) - if (options.fit) params.set('fit', options.fit) - if (options.quality) params.set('quality', options.quality) - if (options.format) params.set('format', options.format) - - const queryString = params.toString() - return `${this.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}` - } +import { client, DirectusError } from './directus/client.js' +import * as authApi from './directus/auth.js' +import * as listingsApi from './directus/listings.js' +import * as categoriesApi from './directus/categories.js' +import * as locationsApi from './directus/locations.js' +import * as conversationsApi from './directus/conversations.js' +import * as filesApi from './directus/files.js' +import * as notificationsApi from './directus/notifications.js' + +const directus = { + // ── Client / Token ── + get baseUrl() { return client.baseUrl }, + get accessToken() { return client.accessToken }, + isAuthenticated: () => client.isAuthenticated(), + clearTokens: () => client.clearTokens(), + + // ── Generic HTTP (used by external services like favorites) ── + get: (endpoint, params) => client.get(endpoint, params), + post: (endpoint, data) => client.post(endpoint, data), + patch: (endpoint, data) => client.patch(endpoint, data), + delete: (endpoint) => client.delete(endpoint), + + // ── Auth ── + login: authApi.login, + logout: authApi.logout, + register: authApi.register, + requestPasswordReset: authApi.requestPasswordReset, + resetPassword: authApi.resetPassword, + getCurrentUser: authApi.getCurrentUser, + updateCurrentUser: authApi.updateCurrentUser, + + // ── Listings ── + getListings: listingsApi.getListings, + getListing: listingsApi.getListing, + createListing: listingsApi.createListing, + updateListing: listingsApi.updateListing, + incrementListingViews: listingsApi.incrementListingViews, + deleteListing: listingsApi.deleteListing, + getMyListings: listingsApi.getMyListings, + searchListings: listingsApi.searchListings, + getListingsByCategory: listingsApi.getListingsByCategory, + getListingsByLocation: listingsApi.getListingsByLocation, + + // ── Categories ── + getCategories: categoriesApi.getCategories, + getCategory: categoriesApi.getCategory, + getCategoryTree: categoriesApi.getCategoryTree, + buildCategoryTree: categoriesApi.buildCategoryTree, + getSubcategories: categoriesApi.getSubcategories, + + // ── Locations ── + getLocations: locationsApi.getLocations, + getLocation: locationsApi.getLocation, + searchLocations: locationsApi.searchLocations, + getLocationsByRegion: locationsApi.getLocationsByRegion, + getLocationsByCountry: locationsApi.getLocationsByCountry, + + // ── Conversations ── + getConversations: conversationsApi.getConversations, + getConversation: conversationsApi.getConversation, + getConversationMessages: conversationsApi.getConversationMessages, + sendMessage: conversationsApi.sendMessage, + startConversation: conversationsApi.startConversation, + findConversation: conversationsApi.findConversation, + updateConversationStatus: conversationsApi.updateConversationStatus, + + // ── Files ── + uploadFile: filesApi.uploadFile, + uploadMultipleFiles: filesApi.uploadMultipleFiles, + getFileUrl: filesApi.getFileUrl, + getThumbnailUrl: filesApi.getThumbnailUrl, // ── Notifications ── - - async getNotifications(userHash, options = {}) { - const params = { - fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'], - filter: { user_hash: { _eq: userHash } }, - sort: ['-date_created'], - limit: options.limit || 50 - } - if (options.unreadOnly) { - params.filter.read = { _eq: false } - } - const response = await this.get('/items/notifications', params) - return response.data - } - - async getUnreadCount(userHash) { - const response = await this.get('/items/notifications', { - filter: { user_hash: { _eq: userHash }, read: { _eq: false } }, - aggregate: { count: 'id' } - }) - return parseInt(response.data?.[0]?.count?.id || '0', 10) - } - - async markNotificationRead(id) { - return this.patch(`/items/notifications/${id}`, { read: true }) - } - - async markAllNotificationsRead(userHash) { - const unread = await this.getNotifications(userHash, { unreadOnly: true }) - const updates = unread.map(n => this.markNotificationRead(n.id)) - return Promise.all(updates) - } - - async deleteNotification(id) { - return this.delete(`/items/notifications/${id}`) - } - - getThumbnailUrl(fileId, size = 300) { - return this.getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 }) - } - + getNotifications: notificationsApi.getNotifications, + getUnreadCount: notificationsApi.getUnreadCount, + markNotificationRead: notificationsApi.markNotificationRead, + markAllNotificationsRead: notificationsApi.markAllNotificationsRead, + deleteNotification: notificationsApi.deleteNotification } -class DirectusError extends Error { - constructor(status, message, data = {}) { - super(message) - this.name = 'DirectusError' - this.status = status - this.data = data - } - - isAuthError() { - return this.status === 401 || this.status === 403 - } - - isNotFound() { - return this.status === 404 - } - - isValidationError() { - return this.status === 400 - } -} - -// Singleton Export -export const directus = new DirectusService() -export { DirectusError } +export { directus, DirectusError } diff --git a/js/services/directus/auth.js b/js/services/directus/auth.js new file mode 100644 index 0000000..cfc0449 --- /dev/null +++ b/js/services/directus/auth.js @@ -0,0 +1,50 @@ +import { client } from './client.js' + +export async function login(email, password) { + const response = await client.post('/auth/login', { email, password }) + + if (response.data) { + client.saveTokens( + response.data.access_token, + response.data.refresh_token, + response.data.expires + ) + } + + return response.data +} + +export async function logout() { + if (client.refreshToken) { + try { + await client.post('/auth/logout', { refresh_token: client.refreshToken }) + } catch (e) { + // Tokens will be cleared anyway + } + } + client.clearTokens() +} + +export async function register(email, password) { + return client.post('/users/register', { email, password }) +} + +export async function requestPasswordReset(email) { + return client.post('/auth/password/request', { email }) +} + +export async function resetPassword(token, password) { + return client.post('/auth/password/reset', { token, password }) +} + +export async function getCurrentUser() { + const response = await client.get('/users/me', { + fields: ['id', 'email', 'first_name', 'last_name', 'avatar', 'role.name', 'status', 'preferred_currency', 'preferred_locale'] + }) + return response.data +} + +export async function updateCurrentUser(data) { + const response = await client.patch('/users/me', data) + return response.data +} diff --git a/js/services/directus/categories.js b/js/services/directus/categories.js new file mode 100644 index 0000000..6015ba5 --- /dev/null +++ b/js/services/directus/categories.js @@ -0,0 +1,55 @@ +import { client } from './client.js' + +export async function getCategories() { + const response = await client.get('/items/categories', { + fields: ['*', 'translations.*'], + filter: { status: { _eq: 'published' } }, + sort: ['sort', 'name'], + limit: -1 + }) + return response.data || [] +} + +export async function getCategory(idOrSlug) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idOrSlug) + + if (isUuid) { + const response = await client.get(`/items/categories/${idOrSlug}`, { + fields: ['*', 'translations.*', 'parent.*'] + }) + return response.data + } + + const response = await client.get('/items/categories', { + fields: ['*', 'translations.*', 'parent.*'], + filter: { slug: { _eq: idOrSlug } }, + limit: 1 + }) + return response.data?.[0] || null +} + +export async function getCategoryTree() { + const categories = await getCategories() + return buildCategoryTree(categories) +} + +export function buildCategoryTree(categories, parentId = null) { + return categories + .filter(cat => (cat.parent?.id || cat.parent) === parentId) + .map(cat => ({ + ...cat, + children: buildCategoryTree(categories, cat.id) + })) +} + +export async function getSubcategories(parentId) { + const response = await client.get('/items/categories', { + fields: ['*', 'translations.*'], + filter: { + status: { _eq: 'published' }, + parent: { _eq: parentId } + }, + sort: ['sort', 'name'] + }) + return response.data +} diff --git a/js/services/directus/client.js b/js/services/directus/client.js new file mode 100644 index 0000000..0fbd568 --- /dev/null +++ b/js/services/directus/client.js @@ -0,0 +1,255 @@ +const DIRECTUS_URL = 'https://api.dgray.io' + +class DirectusError extends Error { + constructor(status, message, data = {}) { + super(message) + this.name = 'DirectusError' + this.status = status + this.data = data + } + + isAuthError() { + return this.status === 401 || this.status === 403 + } + + isNotFound() { + return this.status === 404 + } + + isValidationError() { + return this.status === 400 + } +} + +class DirectusClient { + constructor() { + this.baseUrl = DIRECTUS_URL + this.accessToken = null + this.refreshToken = null + this.tokenExpiry = null + this.refreshTimeout = null + this._refreshPromise = null + + this.loadTokens() + this.setupVisibilityRefresh() + } + + // ── Token Management ── + + loadTokens() { + const stored = localStorage.getItem('dgray_auth') + if (stored) { + try { + const { accessToken, refreshToken, expiry } = JSON.parse(stored) + this.accessToken = accessToken + this.refreshToken = refreshToken + this.tokenExpiry = expiry + this.scheduleTokenRefresh() + } catch (e) { + this.clearTokens() + } + } + } + + saveTokens(accessToken, refreshToken, expiresIn) { + this.accessToken = accessToken + this.refreshToken = refreshToken + this.tokenExpiry = Date.now() + (expiresIn * 1000) + + localStorage.setItem('dgray_auth', JSON.stringify({ + accessToken: this.accessToken, + refreshToken: this.refreshToken, + expiry: this.tokenExpiry + })) + + this.scheduleTokenRefresh() + } + + clearTokens() { + this.accessToken = null + this.refreshToken = null + this.tokenExpiry = null + localStorage.removeItem('dgray_auth') + + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout) + this.refreshTimeout = null + } + } + + scheduleTokenRefresh() { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout) + } + + if (!this.tokenExpiry || !this.refreshToken) return + + const refreshIn = this.tokenExpiry - Date.now() - 60000 + + if (refreshIn > 0) { + this.refreshTimeout = setTimeout(() => this.refreshSession(), refreshIn) + } else if (this.refreshToken) { + this.refreshSession() + } + } + + setupVisibilityRefresh() { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && this.refreshToken) { + const timeLeft = this.tokenExpiry - Date.now() + if (timeLeft < 120000) { + this.refreshSession() + } + this.scheduleTokenRefresh() + } + }) + } + + isAuthenticated() { + return !!this.accessToken && (!this.tokenExpiry || Date.now() < this.tokenExpiry) + } + + async refreshSession(_retryCount = 0) { + if (!this.refreshToken) return false + + try { + const response = await fetch(`${this.baseUrl}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + refresh_token: this.refreshToken, + mode: 'json' + }) + }) + + if (!response.ok) throw new Error('Refresh failed') + + const result = await response.json() + + if (result.data) { + this.saveTokens( + result.data.access_token, + result.data.refresh_token, + result.data.expires + ) + return true + } + } catch (e) { + if (_retryCount < 2) { + await new Promise(r => setTimeout(r, 2000)) + return this.refreshSession(_retryCount + 1) + } + this.clearTokens() + } + + return false + } + + // ── HTTP Methods ── + + async request(endpoint, options = {}, _retryCount = 0) { + const url = `${this.baseUrl}${endpoint}` + + const headers = { + 'Content-Type': 'application/json', + ...options.headers + } + + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}` + } + + try { + const response = await fetch(url, { + ...options, + headers + }) + + if (response.status === 401 && this.refreshToken && !endpoint.startsWith('/auth/') && _retryCount < 1) { + if (!this._refreshPromise) { + this._refreshPromise = this.refreshSession().finally(() => { + this._refreshPromise = null + }) + } + const refreshed = await this._refreshPromise + if (!refreshed) { + this.clearTokens() + } + return this.request(endpoint, options, _retryCount + 1) + } + + if (response.status === 429 && _retryCount < 3) { + const retryAfterHeader = response.headers.get('Retry-After') || '1' + let waitMs + if (parseInt(retryAfterHeader, 10) > 100) { + waitMs = parseInt(retryAfterHeader, 10) + } else { + waitMs = parseInt(retryAfterHeader, 10) * 1000 + } + await new Promise(r => setTimeout(r, waitMs)) + return this.request(endpoint, options, _retryCount + 1) + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new DirectusError(response.status, error.errors?.[0]?.message || 'Request failed', error) + } + + if (response.status === 204) { + return null + } + + return await response.json() + } catch (error) { + if (error instanceof DirectusError) throw error + throw new DirectusError(0, 'Network error', { originalError: error }) + } + } + + async get(endpoint, params = {}) { + const queryString = this.buildQueryString(params) + const url = queryString ? `${endpoint}?${queryString}` : endpoint + return this.request(url, { method: 'GET' }) + } + + async post(endpoint, data) { + return this.request(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }) + } + + async patch(endpoint, data) { + return this.request(endpoint, { + method: 'PATCH', + body: JSON.stringify(data) + }) + } + + async delete(endpoint) { + return this.request(endpoint, { method: 'DELETE' }) + } + + buildQueryString(params) { + const searchParams = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue + + if (key === 'sort' && Array.isArray(value)) { + searchParams.set(key, value.join(',')) + } else if (key === 'fields' && Array.isArray(value)) { + searchParams.set(key, value.join(',')) + } else if (typeof value === 'object') { + searchParams.set(key, JSON.stringify(value)) + } else { + searchParams.set(key, value) + } + } + + return searchParams.toString() + } +} + +export const client = new DirectusClient() +export { DirectusError } diff --git a/js/services/directus/conversations.js b/js/services/directus/conversations.js new file mode 100644 index 0000000..12d803d --- /dev/null +++ b/js/services/directus/conversations.js @@ -0,0 +1,93 @@ +import { client } from './client.js' + +export async function getConversations(participantHash) { + const response = await client.get('/items/conversations', { + fields: [ + '*', + 'listing_id.id', + 'listing_id.title', + 'listing_id.status', + 'listing_id.images.directus_files_id.id' + ], + filter: { + _or: [ + { participant_hash_1: { _eq: participantHash } }, + { participant_hash_2: { _eq: participantHash } } + ] + }, + sort: ['-date_updated'] + }) + return response.data +} + +export async function getConversation(id) { + const response = await client.get(`/items/conversations/${id}`, { + fields: [ + '*', + 'listing_id.*', + 'listing_id.images.directus_files_id.*' + ] + }) + return response.data +} + +export async function getConversationMessages(conversationId) { + const response = await client.get('/items/messages', { + fields: ['*'], + filter: { conversation: { _eq: conversationId } }, + sort: ['date_created'] + }) + return response.data +} + +export async function sendMessage(conversationId, senderHash, encryptedContent, nonce, type = 'text') { + const response = await client.post('/items/messages', { + conversation: conversationId, + sender_hash: senderHash, + content_encrypted: encryptedContent, + nonce: nonce, + type: type + }) + return response.data +} + +export async function startConversation(listingId, participantHash1, participantHash2, publicKey1, publicKey2) { + const response = await client.post('/items/conversations', { + listing_id: listingId, + participant_hash_1: participantHash1, + participant_hash_2: participantHash2, + public_key_1: publicKey1, + public_key_2: publicKey2, + status: 'active' + }) + return response.data +} + +export async function findConversation(listingId, participantHash1, participantHash2) { + const response = await client.get('/items/conversations', { + filter: { + listing_id: { _eq: listingId }, + _or: [ + { + _and: [ + { participant_hash_1: { _eq: participantHash1 } }, + { participant_hash_2: { _eq: participantHash2 } } + ] + }, + { + _and: [ + { participant_hash_1: { _eq: participantHash2 } }, + { participant_hash_2: { _eq: participantHash1 } } + ] + } + ] + }, + limit: 1 + }) + return response.data?.[0] || null +} + +export async function updateConversationStatus(id, status) { + const response = await client.patch(`/items/conversations/${id}`, { status }) + return response.data +} diff --git a/js/services/directus/files.js b/js/services/directus/files.js new file mode 100644 index 0000000..e0d0088 --- /dev/null +++ b/js/services/directus/files.js @@ -0,0 +1,48 @@ +import { client, DirectusError } from './client.js' + +export async function uploadFile(file, options = {}) { + const formData = new FormData() + formData.append('file', file) + + if (options.folder) { + formData.append('folder', options.folder) + } + + const response = await fetch(`${client.baseUrl}/files`, { + method: 'POST', + headers: client.accessToken ? { 'Authorization': `Bearer ${client.accessToken}` } : {}, + body: formData + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new DirectusError(response.status, 'Upload failed', error) + } + + const result = await response.json() + return result.data +} + +export async function uploadMultipleFiles(files, options = {}) { + const uploads = Array.from(files).map(file => uploadFile(file, options)) + return Promise.all(uploads) +} + +export function getFileUrl(fileId, options = {}) { + if (!fileId) return null + + const params = new URLSearchParams() + + if (options.width) params.set('width', options.width) + if (options.height) params.set('height', options.height) + if (options.fit) params.set('fit', options.fit) + if (options.quality) params.set('quality', options.quality) + if (options.format) params.set('format', options.format) + + const queryString = params.toString() + return `${client.baseUrl}/assets/${fileId}${queryString ? '?' + queryString : ''}` +} + +export function getThumbnailUrl(fileId, size = 300) { + return getFileUrl(fileId, { width: size, height: size, fit: 'cover', format: 'webp', quality: 80 }) +} diff --git a/js/services/directus/listings.js b/js/services/directus/listings.js new file mode 100644 index 0000000..5276f58 --- /dev/null +++ b/js/services/directus/listings.js @@ -0,0 +1,163 @@ +import { client } from './client.js' + +const DEFAULT_FIELDS = [ + 'id', + 'status', + 'title', + 'slug', + 'price', + 'currency', + 'condition', + 'expires_at', + 'date_created', + 'user_created', + 'images.directus_files_id.id', + 'category.id', + 'category.name', + 'category.slug', + 'category.icon', + 'location.id', + 'location.name', + 'location.postal_code', + 'location.country', + 'location.latitude', + 'location.longitude' +] + +const DETAIL_FIELDS = [ + 'id', + 'status', + 'title', + 'slug', + 'description', + 'price', + 'currency', + 'price_mode', + 'price_type', + 'condition', + 'shipping', + 'shipping_cost', + 'views', + 'paid_at', + 'payment_status', + 'expires_at', + 'date_created', + 'user_created', + 'images.directus_files_id.id', + 'category.id', + 'category.name', + 'category.slug', + 'category.translations.*', + 'location.id', + 'location.name', + 'location.postal_code', + 'location.country' +] + +export async function getListings(options = {}) { + const params = { + fields: options.fields || DEFAULT_FIELDS, + filter: options.filter || { + status: { _eq: 'published' }, + _or: [ + { expires_at: { _null: true } }, + { expires_at: { _gt: '$NOW' } } + ] + }, + sort: options.sort || ['-date_created'], + limit: options.limit || 20, + page: options.page || 1 + } + + if (options.search) { + params.search = options.search + } + + const response = await client.get('/items/listings', params) + return { + items: response.data, + meta: response.meta + } +} + +export async function getListing(id) { + const response = await client.get(`/items/listings/${id}`, { + fields: DETAIL_FIELDS + }) + return response.data +} + +export async function createListing(data) { + const response = await client.post('/items/listings', data) + return response?.data || response +} + +export async function updateListing(id, data) { + const response = await client.patch(`/items/listings/${id}`, data) + return response.data +} + +export async function incrementListingViews(id) { + const viewedKey = `dgray_viewed_${id}` + if (sessionStorage.getItem(viewedKey)) { + return null + } + + try { + const listing = await client.get(`/items/listings/${id}`, { + fields: ['views'] + }) + const currentViews = listing.data?.views || 0 + const newViews = currentViews + 1 + + await client.patch(`/items/listings/${id}`, { + views: newViews + }) + + sessionStorage.setItem(viewedKey, 'true') + return newViews + } catch (error) { + console.error('Failed to increment views:', error) + return null + } +} + +export async function deleteListing(id) { + return client.delete(`/items/listings/${id}`) +} + +export async function getMyListings() { + const response = await client.get('/items/listings', { + fields: ['*', 'images.directus_files_id.id', 'category.id', 'category.name', 'location.name'], + filter: { user_created: { _eq: '$CURRENT_USER' } }, + sort: ['-date_created'] + }) + return response.data || [] +} + +export async function searchListings(query, options = {}) { + return getListings({ + search: query, + ...options + }) +} + +export async function getListingsByCategory(categoryId, options = {}) { + return getListings({ + filter: { + status: { _eq: 'published' }, + category: { _eq: categoryId } + }, + ...options + }) +} + +export async function getListingsByLocation(locationId, options = {}) { + return getListings({ + filter: { + status: { _eq: 'published' }, + location: { _eq: locationId } + }, + ...options + }) +} diff --git a/js/services/directus/locations.js b/js/services/directus/locations.js new file mode 100644 index 0000000..71d93b0 --- /dev/null +++ b/js/services/directus/locations.js @@ -0,0 +1,40 @@ +import { client } from './client.js' + +export async function getLocations(options = {}) { + const response = await client.get('/items/locations', { + fields: options.fields || ['*'], + filter: options.filter || {}, + sort: options.sort || ['name'], + limit: options.limit || -1 + }) + return response.data +} + +export async function getLocation(id) { + const response = await client.get(`/items/locations/${id}`) + return response.data +} + +export async function searchLocations(query) { + const response = await client.get('/items/locations', { + search: query, + limit: 20 + }) + return response.data +} + +export async function getLocationsByRegion(region) { + const response = await client.get('/items/locations', { + filter: { region: { _eq: region } }, + sort: ['name'] + }) + return response.data +} + +export async function getLocationsByCountry(country) { + const response = await client.get('/items/locations', { + filter: { country: { _eq: country } }, + sort: ['region', 'name'] + }) + return response.data +} diff --git a/js/services/directus/notifications.js b/js/services/directus/notifications.js new file mode 100644 index 0000000..e457838 --- /dev/null +++ b/js/services/directus/notifications.js @@ -0,0 +1,37 @@ +import { client } from './client.js' + +export async function getNotifications(userHash, options = {}) { + const params = { + fields: ['id', 'type', 'reference_id', 'read', 'date_created', 'user_hash'], + filter: { user_hash: { _eq: userHash } }, + sort: ['-date_created'], + limit: options.limit || 50 + } + if (options.unreadOnly) { + params.filter.read = { _eq: false } + } + const response = await client.get('/items/notifications', params) + return response.data +} + +export async function getUnreadCount(userHash) { + const response = await client.get('/items/notifications', { + filter: { user_hash: { _eq: userHash }, read: { _eq: false } }, + aggregate: { count: 'id' } + }) + return parseInt(response.data?.[0]?.count?.id || '0', 10) +} + +export async function markNotificationRead(id) { + return client.patch(`/items/notifications/${id}`, { read: true }) +} + +export async function markAllNotificationsRead(userHash) { + const unread = await getNotifications(userHash, { unreadOnly: true }) + const updates = unread.map(n => markNotificationRead(n.id)) + return Promise.all(updates) +} + +export async function deleteNotification(id) { + return client.delete(`/items/notifications/${id}`) +} diff --git a/service-worker.js b/service-worker.js index 8df8448..9bbd93e 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'dgray-v50'; +const CACHE_NAME = 'dgray-v51'; const STATIC_ASSETS = [ '/', '/index.html', @@ -18,6 +18,14 @@ const STATIC_ASSETS = [ // Services '/js/services/directus.js', + '/js/services/directus/client.js', + '/js/services/directus/auth.js', + '/js/services/directus/listings.js', + '/js/services/directus/categories.js', + '/js/services/directus/locations.js', + '/js/services/directus/conversations.js', + '/js/services/directus/files.js', + '/js/services/directus/notifications.js', '/js/services/auth.js', '/js/services/listings.js', '/js/services/categories.js',