upd: modal view pengumuman
Deskripsi: - modal view image pada detail pengumuman NO Issues
This commit is contained in:
@@ -4,6 +4,7 @@ import BorderBottomItem from "@/components/borderBottomItem";
|
|||||||
import Skeleton from "@/components/skeleton";
|
import Skeleton from "@/components/skeleton";
|
||||||
import Text from '@/components/Text';
|
import Text from '@/components/Text';
|
||||||
import { ConstEnv } from "@/constants/ConstEnv";
|
import { ConstEnv } from "@/constants/ConstEnv";
|
||||||
|
import { isImageFile } from "@/constants/FileExtensions";
|
||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiGetAnnouncementOne } from "@/lib/api";
|
import { apiGetAnnouncementOne } from "@/lib/api";
|
||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
@@ -13,24 +14,46 @@ import { startActivityAsync } from 'expo-intent-launcher';
|
|||||||
import { router, Stack, useLocalSearchParams } from "expo-router";
|
import { router, Stack, useLocalSearchParams } from "expo-router";
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Alert, Dimensions, Platform, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
import { Alert, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, ScrollView, View } from "react-native";
|
||||||
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
import * as mime from 'react-native-mime-types';
|
import * as mime from 'react-native-mime-types';
|
||||||
import RenderHTML from 'react-native-render-html';
|
import RenderHTML from 'react-native-render-html';
|
||||||
import Toast from "react-native-toast-message";
|
import Toast from "react-native-toast-message";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
type Props = {
|
// Define TypeScript interfaces for better type safety
|
||||||
id: string
|
interface AnnouncementData {
|
||||||
title: string
|
id: string;
|
||||||
desc: string
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileData {
|
||||||
|
id: string;
|
||||||
|
idStorage: string;
|
||||||
|
name: string;
|
||||||
|
extension: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberData {
|
||||||
|
group: string;
|
||||||
|
division: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: AnnouncementData;
|
||||||
|
member: Record<string, MemberData[]>;
|
||||||
|
file: FileData[];
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DetailAnnouncement() {
|
export default function DetailAnnouncement() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const [data, setData] = useState<Props>({ id: '', title: '', desc: '' })
|
const [data, setData] = useState<AnnouncementData>({ id: '', title: '', desc: '' })
|
||||||
const [dataMember, setDataMember] = useState<any>({})
|
const [dataMember, setDataMember] = useState<Record<string, MemberData[]>>({})
|
||||||
const [dataFile, setDataFile] = useState<{ id: string; idStorage: string; name: string; extension: string }[]>([])
|
const [dataFile, setDataFile] = useState<FileData[]>([])
|
||||||
const update = useSelector((state: any) => state.announcementUpdate)
|
const update = useSelector((state: any) => state.announcementUpdate)
|
||||||
const entityUser = useSelector((state: any) => state.user)
|
const entityUser = useSelector((state: any) => state.user)
|
||||||
const contentWidth = Dimensions.get('window').width
|
const contentWidth = Dimensions.get('window').width
|
||||||
@@ -38,13 +61,24 @@ export default function DetailAnnouncement() {
|
|||||||
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
|
const arrSkeleton = Array.from({ length: 2 }, (_, index) => index)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [loadingOpen, setLoadingOpen] = useState(false)
|
const [loadingOpen, setLoadingOpen] = useState(false)
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
const [chooseFile, setChooseFile] = useState<FileData>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the image preview modal for the selected image file
|
||||||
|
* @param item The file data object containing image information
|
||||||
|
*/
|
||||||
|
function handleChooseFile(item: FileData) {
|
||||||
|
setChooseFile(item)
|
||||||
|
setPreview(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleLoad(loading: boolean) {
|
async function handleLoad(loading: boolean) {
|
||||||
try {
|
try {
|
||||||
setLoading(loading)
|
setLoading(loading)
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiGetAnnouncementOne({ id: id, user: hasil })
|
const response: ApiResponse = await apiGetAnnouncementOne({ id: id, user: hasil })
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setData(response.data)
|
setData(response.data)
|
||||||
setDataMember(response.member)
|
setDataMember(response.member)
|
||||||
@@ -68,30 +102,47 @@ export default function DetailAnnouncement() {
|
|||||||
handleLoad(true)
|
handleLoad(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a string contains HTML tags
|
||||||
|
* @param text The text to check for HTML tags
|
||||||
|
* @returns True if the text contains HTML tags, false otherwise
|
||||||
|
*/
|
||||||
function hasHtmlTags(text: string) {
|
function hasHtmlTags(text: string) {
|
||||||
const htmlRegex = /<[a-z][\s\S]*>/i;
|
const htmlRegex = /<[a-z][\s\S]*>/i;
|
||||||
return htmlRegex.test(text);
|
return htmlRegex.test(text);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles pull-to-refresh functionality
|
||||||
|
* Reloads the announcement data without showing loading indicators
|
||||||
|
*/
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
handleLoad(false)
|
handleLoad(false)
|
||||||
|
// Simulate network request delay for better UX
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFile = (item: { idStorage: string; name: string; extension: string }) => {
|
const openFile = async (item: FileData) => {
|
||||||
if (Platform.OS == 'android') setLoadingOpen(true)
|
try {
|
||||||
let remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
|
setLoadingOpen(true);
|
||||||
const fileName = item.name + '.' + item.extension;
|
const remoteUrl = ConstEnv.url_storage + '/files/' + item.idStorage;
|
||||||
let localPath = `${FileSystem.documentDirectory}/${fileName}`;
|
const fileName = item.name + '.' + item.extension;
|
||||||
const mimeType = mime.lookup(fileName)
|
const localPath = `${FileSystem.documentDirectory}/${fileName}`;
|
||||||
|
const mimeType = mime.lookup(fileName);
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
const downloadResult = await FileSystem.downloadAsync(remoteUrl, localPath);
|
||||||
|
|
||||||
|
if (downloadResult.status !== 200) {
|
||||||
|
throw new Error(`Download failed with status ${downloadResult.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentURL = await FileSystem.getContentUriAsync(downloadResult.uri);
|
||||||
|
|
||||||
FileSystem.downloadAsync(remoteUrl, localPath).then(async ({ uri }) => {
|
|
||||||
const contentURL = await FileSystem.getContentUriAsync(uri);
|
|
||||||
setLoadingOpen(false)
|
|
||||||
try {
|
try {
|
||||||
if (Platform.OS == 'android') {
|
if (Platform.OS === 'android') {
|
||||||
await startActivityAsync(
|
await startActivityAsync(
|
||||||
'android.intent.action.VIEW',
|
'android.intent.action.VIEW',
|
||||||
{
|
{
|
||||||
@@ -100,15 +151,24 @@ export default function DetailAnnouncement() {
|
|||||||
type: mimeType as string,
|
type: mimeType as string,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (Platform.OS == 'ios') {
|
} else if (Platform.OS === 'ios') {
|
||||||
Sharing.shareAsync(localPath);
|
await Sharing.shareAsync(localPath);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (openError) {
|
||||||
Alert.alert('INFO', 'Gagal membuka file, tidak ada aplikasi yang dapat membuka file ini');
|
console.error('Error opening file:', openError);
|
||||||
} finally {
|
Alert.alert('INFO', 'Tidak ada aplikasi yang dapat membuka file ini');
|
||||||
if (Platform.OS == 'android') setLoadingOpen(false)
|
|
||||||
}
|
}
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error('Error downloading or opening file:', error);
|
||||||
|
Alert.alert('INFO', 'Gagal mengunduh atau membuka file');
|
||||||
|
Toast.show({
|
||||||
|
type: 'error',
|
||||||
|
text1: 'Gagal membuka file',
|
||||||
|
text2: 'Silakan coba lagi nanti'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,12 +243,20 @@ export default function DetailAnnouncement() {
|
|||||||
</View>
|
</View>
|
||||||
{dataFile.map((item, index) => (
|
{dataFile.map((item, index) => (
|
||||||
<BorderBottomItem
|
<BorderBottomItem
|
||||||
key={index}
|
key={`${item.id}-${index}`}
|
||||||
borderType="bottom"
|
borderType="bottom"
|
||||||
icon={<MaterialCommunityIcons name="file-outline" size={25} color="black" />}
|
icon={<MaterialCommunityIcons
|
||||||
title={item.name}
|
name={isImageFile(item.extension) ? "file-image-outline" : "file-document-outline"}
|
||||||
|
size={25}
|
||||||
|
color="black"
|
||||||
|
/>}
|
||||||
|
title={item.name + '.' + item.extension}
|
||||||
titleWeight="normal"
|
titleWeight="normal"
|
||||||
onPress={() => { openFile({ idStorage: item.idStorage, name: item.name, extension: item.extension }) }}
|
onPress={() => {
|
||||||
|
isImageFile(item.extension) ?
|
||||||
|
handleChooseFile(item)
|
||||||
|
: openFile(item)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -229,6 +297,45 @@ export default function DetailAnnouncement() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<ImageViewing
|
||||||
|
images={[{ uri: `${ConstEnv.url_storage}/files/${chooseFile?.idStorage}` }]}
|
||||||
|
imageIndex={0}
|
||||||
|
visible={preview}
|
||||||
|
onRequestClose={() => setPreview(false)}
|
||||||
|
doubleTapToZoomEnabled
|
||||||
|
HeaderComponent={({ imageIndex }) => (
|
||||||
|
<View style={[Styles.headerModalViewImg]}>
|
||||||
|
{/* CLOSE */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setPreview(false)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close image viewer"
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', fontSize: 26 }}>✕</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* MENU */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => chooseFile && openFile(chooseFile)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Download or share image"
|
||||||
|
disabled={loadingOpen}
|
||||||
|
>
|
||||||
|
<Text style={{ color: loadingOpen ? 'gray' : 'white', fontSize: 22 }}>⋯</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
FooterComponent={({ imageIndex }) => (
|
||||||
|
<View style={{
|
||||||
|
paddingBottom: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: 'white', fontSize: 16 }}>{chooseFile?.name}.{chooseFile?.extension}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
130
constants/FileExtensions.ts
Normal file
130
constants/FileExtensions.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* File Extensions Constants
|
||||||
|
* Categorizes common file extensions for use throughout the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Image file extensions
|
||||||
|
export const IMAGE_EXTENSIONS = [
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'bmp',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'tiff',
|
||||||
|
'ico'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Document file extensions
|
||||||
|
export const DOCUMENT_EXTENSIONS = [
|
||||||
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'txt',
|
||||||
|
'rtf',
|
||||||
|
'odt',
|
||||||
|
'ods',
|
||||||
|
'odp',
|
||||||
|
'csv',
|
||||||
|
'xml',
|
||||||
|
'html',
|
||||||
|
'htm'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Video file extensions
|
||||||
|
export const VIDEO_EXTENSIONS = [
|
||||||
|
'mp4',
|
||||||
|
'avi',
|
||||||
|
'mov',
|
||||||
|
'wmv',
|
||||||
|
'flv',
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'm4v',
|
||||||
|
'3gp',
|
||||||
|
'mpeg',
|
||||||
|
'mpg'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Audio file extensions
|
||||||
|
export const AUDIO_EXTENSIONS = [
|
||||||
|
'mp3',
|
||||||
|
'wav',
|
||||||
|
'flac',
|
||||||
|
'aac',
|
||||||
|
'ogg',
|
||||||
|
'wma',
|
||||||
|
'm4a',
|
||||||
|
'opus'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Archive file extensions
|
||||||
|
export const ARCHIVE_EXTENSIONS = [
|
||||||
|
'zip',
|
||||||
|
'rar',
|
||||||
|
'7z',
|
||||||
|
'tar',
|
||||||
|
'gz',
|
||||||
|
'bz2',
|
||||||
|
'xz',
|
||||||
|
'iso',
|
||||||
|
'dmg'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Combined list of all extensions
|
||||||
|
export const ALL_EXTENSIONS = [
|
||||||
|
...IMAGE_EXTENSIONS,
|
||||||
|
...DOCUMENT_EXTENSIONS,
|
||||||
|
...VIDEO_EXTENSIONS,
|
||||||
|
...AUDIO_EXTENSIONS,
|
||||||
|
...ARCHIVE_EXTENSIONS
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function to get file type category based on extension
|
||||||
|
export const getFileTypeCategory = (extension: string): string => {
|
||||||
|
const ext = extension.toLowerCase();
|
||||||
|
|
||||||
|
if (IMAGE_EXTENSIONS.includes(ext)) {
|
||||||
|
return 'image';
|
||||||
|
} else if (DOCUMENT_EXTENSIONS.includes(ext)) {
|
||||||
|
return 'document';
|
||||||
|
} else if (VIDEO_EXTENSIONS.includes(ext)) {
|
||||||
|
return 'video';
|
||||||
|
} else if (AUDIO_EXTENSIONS.includes(ext)) {
|
||||||
|
return 'audio';
|
||||||
|
} else if (ARCHIVE_EXTENSIONS.includes(ext)) {
|
||||||
|
return 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a file is an image
|
||||||
|
export const isImageFile = (extension: string): boolean => {
|
||||||
|
return IMAGE_EXTENSIONS.includes(extension.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a file is a document
|
||||||
|
export const isDocumentFile = (extension: string): boolean => {
|
||||||
|
return DOCUMENT_EXTENSIONS.includes(extension.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a file is a video
|
||||||
|
export const isVideoFile = (extension: string): boolean => {
|
||||||
|
return VIDEO_EXTENSIONS.includes(extension.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a file is audio
|
||||||
|
export const isAudioFile = (extension: string): boolean => {
|
||||||
|
return AUDIO_EXTENSIONS.includes(extension.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if a file is an archive
|
||||||
|
export const isArchiveFile = (extension: string): boolean => {
|
||||||
|
return ARCHIVE_EXTENSIONS.includes(extension.toLowerCase());
|
||||||
|
};
|
||||||
@@ -680,6 +680,13 @@ const Styles = StyleSheet.create({
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: 2,
|
padding: 2,
|
||||||
},
|
},
|
||||||
|
headerModalViewImg: {
|
||||||
|
paddingTop: 50,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default Styles;
|
export default Styles;
|
||||||
Reference in New Issue
Block a user