feat: UX improvements, security hardening, and code cleanup

- Extract shared extractError utility (lib/extractError.ts), remove 3 duplicate copies
- Export DEFAULT_BASE_URL from PostizContext, remove duplicate in settings
- Add 401 interceptor in axios client: fires UnauthorizedHandler in _layout → alert + redirect to Settings
- Calendar day items now tappable: tap opens context menu (Copy / Edit / Repost)
- Persist sort order (newest/oldest) across sessions via AsyncStorage
- Filter chips show post count per status (Queue 3, Error 1, etc.)
- Copy text action now shows a brief "Copied" toast + haptic feedback
- PostCard: swipe right → Reschedule action (QUEUE posts only, amber color)
- Compose: per-network char limit (Twitter 280, Instagram 2200…) with color warning at 90%
- Compose: local draft save/restore via AsyncStorage with restore banner on open
- Compose: prefillImagePath/prefillImageId params allow Edit/Repost to carry over existing media

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:20:56 +02:00
parent bc0973ccaa
commit 7aacb9a53e
8 changed files with 419 additions and 112 deletions
@@ -5,12 +5,13 @@ import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
const API_KEY_STORAGE = "postiz_api_key";
const BASE_URL_STORAGE = "postiz_base_url";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizIntegration {
id: string;
@@ -48,6 +49,8 @@ interface PostizContextValue {
baseUrl: string;
isConfigured: boolean;
isLoading: boolean;
unauthorized: boolean;
clearUnauthorized: () => void;
client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>;
@@ -58,12 +61,18 @@ const PostizContext = createContext<PostizContextValue>({
baseUrl: DEFAULT_BASE_URL,
isConfigured: false,
isLoading: true,
unauthorized: false,
clearUnauthorized: () => {},
client: null,
saveSettings: async () => {},
clearSettings: async () => {},
});
function createClient(apiKey: string, baseUrl: string): AxiosInstance {
function createClient(
apiKey: string,
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({
baseURL: normalizedUrl,
@@ -77,6 +86,15 @@ function createClient(apiKey: string, baseUrl: string): AxiosInstance {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || ""));
return config;
});
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance;
}
@@ -85,6 +103,19 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => {
(async () => {
@@ -95,14 +126,14 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
setApiKey(storedKey);
setBaseUrl(url);
setClient(() => createClient(storedKey, url));
setClient(() => createClient(storedKey, url, handleUnauthorized));
}
} catch {
} finally {
setIsLoading(false);
}
})();
}, []);
}, [handleUnauthorized]);
const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => {
@@ -110,9 +141,10 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey);
setBaseUrl(newBaseUrl);
setClient(() => createClient(newApiKey, newBaseUrl));
clearUnauthorized();
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized));
},
[]
[handleUnauthorized, clearUnauthorized]
);
const clearSettings = useCallback(async () => {
@@ -121,7 +153,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
setApiKey("");
setBaseUrl(DEFAULT_BASE_URL);
setClient(null);
}, []);
clearUnauthorized();
}, [clearUnauthorized]);
return (
<PostizContext.Provider
@@ -130,6 +163,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
baseUrl,
isConfigured: !!apiKey,
isLoading,
unauthorized,
clearUnauthorized,
client,
saveSettings,
clearSettings,