finished assignment 3 - 100%
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react"
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||
|
||||
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||
import { supabase } from "@/libs/supabase"
|
||||
import { deleteNoteImage, NoteImageUploadProgress, uploadNoteImage } from "@/src/notes/note-image-storage"
|
||||
import { StagedNoteImage } from "@/src/notes/image-utils"
|
||||
|
||||
type NoteRow = {
|
||||
id: number
|
||||
@@ -9,6 +12,10 @@ type NoteRow = {
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
image_url?: string | null
|
||||
image_path?: string | null
|
||||
image_mime_type?: string | null
|
||||
image_size_bytes?: number | null
|
||||
}
|
||||
|
||||
type ProfileRow = {
|
||||
@@ -26,20 +33,53 @@ export type Note = {
|
||||
title: string
|
||||
content: string
|
||||
creatorLabel: string
|
||||
imageUrl: string | null
|
||||
imagePath: string | null
|
||||
imageMimeType: string | null
|
||||
imageSizeBytes: number | null
|
||||
}
|
||||
|
||||
export type NoteImageChange =
|
||||
| { type: "keep" }
|
||||
| { type: "remove" }
|
||||
| { type: "replace"; image: StagedNoteImage }
|
||||
|
||||
type NotesContextValue = {
|
||||
notes: Note[]
|
||||
isLoading: boolean
|
||||
errorMessage: string | null
|
||||
refreshNotes: () => Promise<void>
|
||||
addNote: (title: string, content: string) => Promise<boolean>
|
||||
updateNote: (noteId: string, title: string, content: string) => Promise<boolean>
|
||||
addNote: (
|
||||
title: string,
|
||||
content: string,
|
||||
image?: StagedNoteImage | null,
|
||||
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||
) => Promise<boolean>
|
||||
updateNote: (
|
||||
noteId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
imageChange?: NoteImageChange,
|
||||
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||
) => Promise<boolean>
|
||||
deleteNote: (noteId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const NotesContext = createContext<NotesContextValue | undefined>(undefined)
|
||||
|
||||
function normalizeImageSizeBytes(value: number | string | null | undefined) {
|
||||
if (typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
const { claims, isLoggedIn, profile } = useAuthContext()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
@@ -81,19 +121,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
const mapNote = (row: NoteRow, labels: Record<string, string>): Note => ({
|
||||
id: String(row.id),
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
lastChangedAt: row.updated_at || row.created_at,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
creatorLabel:
|
||||
labels[row.created_by] ||
|
||||
(row.created_by === userId ? creatorLabel : "Unknown user"),
|
||||
})
|
||||
|
||||
const loadNotes = async () => {
|
||||
const loadNotes = useCallback(async () => {
|
||||
if (!isLoggedIn) {
|
||||
setNotes([])
|
||||
setErrorMessage(null)
|
||||
@@ -106,7 +134,9 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("Notes")
|
||||
.select("id, created_by, title, content, created_at, updated_at")
|
||||
.select(
|
||||
"id, created_by, title, content, created_at, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
|
||||
)
|
||||
.order("updated_at", { ascending: false, nullsFirst: false })
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
@@ -118,12 +148,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const rows = (data ?? []) as NoteRow[]
|
||||
// Resolve public creator labels separately so note reads do not depend on a view schema.
|
||||
const labels = await buildCreatorLabels(rows)
|
||||
|
||||
setNotes(rows.map((row) => mapNote(row, labels)))
|
||||
setNotes(
|
||||
rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
lastChangedAt: row.updated_at || row.created_at,
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
creatorLabel:
|
||||
labels[row.created_by] ||
|
||||
(row.created_by === userId ? creatorLabel : "Unknown user"),
|
||||
imageUrl: row.image_url ?? null,
|
||||
imagePath: row.image_path ?? null,
|
||||
imageMimeType: row.image_mime_type ?? null,
|
||||
imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes),
|
||||
}))
|
||||
)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [creatorLabel, isLoggedIn, userId])
|
||||
|
||||
const refreshNotes = async () => {
|
||||
await loadNotes()
|
||||
@@ -138,14 +183,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
void loadNotes()
|
||||
}, [creatorLabel, isLoggedIn, userId])
|
||||
}, [creatorLabel, isLoggedIn, loadNotes, userId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !userId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Poll for remote changes so other users' edits appear without a manual refresh.
|
||||
const intervalId = setInterval(() => {
|
||||
void loadNotes()
|
||||
}, 30000)
|
||||
@@ -153,9 +197,14 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}, [creatorLabel, isLoggedIn, userId])
|
||||
}, [creatorLabel, isLoggedIn, loadNotes, userId])
|
||||
|
||||
const addNote = async (title: string, content: string) => {
|
||||
const addNote = async (
|
||||
title: string,
|
||||
content: string,
|
||||
image?: StagedNoteImage | null,
|
||||
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||
) => {
|
||||
const trimmedTitle = title.trim()
|
||||
const trimmedContent = content.trim()
|
||||
|
||||
@@ -170,16 +219,47 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
|
||||
const { error } = await supabase
|
||||
.from("Notes")
|
||||
.insert({
|
||||
title: trimmedTitle,
|
||||
content: trimmedContent,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message)
|
||||
let uploadedImage:
|
||||
| {
|
||||
path: string
|
||||
publicUrl: string
|
||||
mimeType: string
|
||||
sizeBytes: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
try {
|
||||
if (image) {
|
||||
uploadedImage = await uploadNoteImage(userId, image, {
|
||||
onProgress: options?.onImageUploadProgress,
|
||||
})
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("Notes")
|
||||
.insert({
|
||||
title: trimmedTitle,
|
||||
content: trimmedContent,
|
||||
image_url: uploadedImage?.publicUrl ?? null,
|
||||
image_path: uploadedImage?.path ?? null,
|
||||
image_mime_type: uploadedImage?.mimeType ?? null,
|
||||
image_size_bytes: uploadedImage?.sizeBytes ?? null,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (uploadedImage?.path) {
|
||||
try {
|
||||
await deleteNoteImage(uploadedImage.path)
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to roll back uploaded note image:", cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to save note.")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -187,7 +267,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
return true
|
||||
}
|
||||
|
||||
const updateNote = async (noteId: string, title: string, content: string) => {
|
||||
const updateNote = async (
|
||||
noteId: string,
|
||||
title: string,
|
||||
content: string,
|
||||
imageChange: NoteImageChange = { type: "keep" },
|
||||
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||
) => {
|
||||
const trimmedTitle = title.trim()
|
||||
const trimmedContent = content.trim()
|
||||
|
||||
@@ -201,44 +287,109 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingNote = notes.find((note) => note.id === noteId)
|
||||
|
||||
if (!existingNote) {
|
||||
setErrorMessage("This note could not be found.")
|
||||
return false
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("Notes")
|
||||
.update({
|
||||
title: trimmedTitle,
|
||||
content: trimmedContent,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", Number(noteId))
|
||||
.eq("created_by", userId)
|
||||
.select("id, title, content, updated_at")
|
||||
.maybeSingle()
|
||||
let uploadedImage:
|
||||
| {
|
||||
path: string
|
||||
publicUrl: string
|
||||
mimeType: string
|
||||
sizeBytes: number
|
||||
}
|
||||
| null = null
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message)
|
||||
return false
|
||||
const updates: Record<string, string | number | null> = {
|
||||
title: trimmedTitle,
|
||||
content: trimmedContent,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
setErrorMessage("Update failed. You can only edit notes that you created.")
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (imageChange.type === "replace") {
|
||||
uploadedImage = await uploadNoteImage(userId, imageChange.image, {
|
||||
onProgress: options?.onImageUploadProgress,
|
||||
})
|
||||
updates.image_url = uploadedImage.publicUrl
|
||||
updates.image_path = uploadedImage.path
|
||||
updates.image_mime_type = uploadedImage.mimeType
|
||||
updates.image_size_bytes = uploadedImage.sizeBytes
|
||||
} else if (imageChange.type === "remove") {
|
||||
updates.image_url = null
|
||||
updates.image_path = null
|
||||
updates.image_mime_type = null
|
||||
updates.image_size_bytes = null
|
||||
}
|
||||
|
||||
// Update the edited note locally so the new timestamp is visible immediately.
|
||||
setNotes((prev) =>
|
||||
prev.map((note) =>
|
||||
note.id === noteId
|
||||
? {
|
||||
...note,
|
||||
title: data.title ?? trimmedTitle,
|
||||
content: data.content ?? trimmedContent,
|
||||
lastChangedAt: data.updated_at ?? new Date().toISOString(),
|
||||
}
|
||||
: note
|
||||
const { data, error } = await supabase
|
||||
.from("Notes")
|
||||
.update(updates)
|
||||
.eq("id", Number(noteId))
|
||||
.eq("created_by", userId)
|
||||
.select(
|
||||
"id, title, content, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
|
||||
)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Update failed. You can only edit notes that you created.")
|
||||
}
|
||||
|
||||
if (imageChange.type === "replace" && existingNote.imagePath) {
|
||||
try {
|
||||
await deleteNoteImage(existingNote.imagePath)
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to remove replaced note image:", cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageChange.type === "remove" && existingNote.imagePath) {
|
||||
try {
|
||||
await deleteNoteImage(existingNote.imagePath)
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to remove deleted note image:", cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
setNotes((prev) =>
|
||||
prev.map((note) =>
|
||||
note.id === noteId
|
||||
? {
|
||||
...note,
|
||||
title: data.title ?? trimmedTitle,
|
||||
content: data.content ?? trimmedContent,
|
||||
lastChangedAt: data.updated_at ?? updates.updated_at ?? new Date().toISOString(),
|
||||
imageUrl: data.image_url ?? null,
|
||||
imagePath: data.image_path ?? null,
|
||||
imageMimeType: data.image_mime_type ?? null,
|
||||
imageSizeBytes: normalizeImageSizeBytes(data.image_size_bytes),
|
||||
}
|
||||
: note
|
||||
)
|
||||
)
|
||||
)
|
||||
return true
|
||||
return true
|
||||
} catch (error) {
|
||||
if (uploadedImage?.path) {
|
||||
try {
|
||||
await deleteNoteImage(uploadedImage.path)
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to roll back uploaded replacement image:", cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
setErrorMessage(error instanceof Error ? error.message : "Failed to update note.")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNote = async (noteId: string) => {
|
||||
@@ -247,6 +398,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingNote = notes.find((note) => note.id === noteId)
|
||||
|
||||
setErrorMessage(null)
|
||||
|
||||
const { error } = await supabase
|
||||
@@ -260,6 +413,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (existingNote?.imagePath) {
|
||||
try {
|
||||
await deleteNoteImage(existingNote.imagePath)
|
||||
} catch (cleanupError) {
|
||||
console.error("Failed to remove note image during deletion:", cleanupError)
|
||||
setErrorMessage("The note was deleted, but its stored image could not be cleaned up.")
|
||||
}
|
||||
}
|
||||
|
||||
setNotes((prev) => prev.filter((note) => note.id !== noteId))
|
||||
return true
|
||||
}
|
||||
|
||||
199
FastNotes/src/notes/image-utils.ts
Normal file
199
FastNotes/src/notes/image-utils.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import * as ImageManipulator from "expo-image-manipulator"
|
||||
|
||||
export const NOTE_IMAGE_BUCKET = "note-images"
|
||||
export const MAX_NOTE_IMAGE_BYTES = 15 * 1024 * 1024
|
||||
export const SUPPORTED_NOTE_IMAGE_FORMATS = "PNG, JPG/JPEG, WEBP"
|
||||
|
||||
const JPEG_MIME = "image/jpeg"
|
||||
const PNG_MIME = "image/png"
|
||||
const WEBP_MIME = "image/webp"
|
||||
|
||||
export type SupportedMimeType =
|
||||
| typeof JPEG_MIME
|
||||
| typeof PNG_MIME
|
||||
| typeof WEBP_MIME
|
||||
|
||||
export type StagedNoteImage = {
|
||||
uri: string
|
||||
fileName: string
|
||||
mimeType: string | null
|
||||
fileSize: number | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
}
|
||||
|
||||
export type UploadedNoteImage = {
|
||||
path: string
|
||||
publicUrl: string
|
||||
mimeType: SupportedMimeType
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type PreparedNoteImage = {
|
||||
uri: string
|
||||
mimeType: SupportedMimeType
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
function normalizeFileExtension(fileName: string | null | undefined) {
|
||||
const extension = fileName?.split(".").pop()?.toLowerCase()
|
||||
|
||||
if (!extension) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (extension === "jpg") {
|
||||
return "jpeg"
|
||||
}
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
export function normalizeMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
fileName?: string | null
|
||||
): SupportedMimeType | null {
|
||||
if (mimeType === JPEG_MIME || mimeType === PNG_MIME || mimeType === WEBP_MIME) {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
if (mimeType === "image/jpg") {
|
||||
return JPEG_MIME
|
||||
}
|
||||
|
||||
const extension = normalizeFileExtension(fileName)
|
||||
|
||||
if (extension === "jpeg") {
|
||||
return JPEG_MIME
|
||||
}
|
||||
|
||||
if (extension === "png") {
|
||||
return PNG_MIME
|
||||
}
|
||||
|
||||
if (extension === "webp") {
|
||||
return WEBP_MIME
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getFileExtension(mimeType: SupportedMimeType) {
|
||||
if (mimeType === PNG_MIME) {
|
||||
return "png"
|
||||
}
|
||||
|
||||
if (mimeType === WEBP_MIME) {
|
||||
return "webp"
|
||||
}
|
||||
|
||||
return "jpg"
|
||||
}
|
||||
|
||||
function getSaveFormat(mimeType: SupportedMimeType) {
|
||||
if (mimeType === PNG_MIME) {
|
||||
return ImageManipulator.SaveFormat.PNG
|
||||
}
|
||||
|
||||
if (mimeType === WEBP_MIME) {
|
||||
return ImageManipulator.SaveFormat.WEBP
|
||||
}
|
||||
|
||||
return ImageManipulator.SaveFormat.JPEG
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number | null | undefined) {
|
||||
if (!bytes || Number.isNaN(bytes) || bytes <= 0) {
|
||||
return "Unknown size"
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function validateStagedNoteImage(image: StagedNoteImage) {
|
||||
const normalizedMimeType = normalizeMimeType(image.mimeType, image.fileName)
|
||||
|
||||
if (!normalizedMimeType) {
|
||||
throw new Error(`Unsupported image format. Allowed formats: ${SUPPORTED_NOTE_IMAGE_FORMATS}.`)
|
||||
}
|
||||
|
||||
return normalizedMimeType
|
||||
}
|
||||
|
||||
async function getFileSize(uri: string) {
|
||||
const response = await fetch(uri)
|
||||
const blob = await response.blob()
|
||||
return blob.size
|
||||
}
|
||||
|
||||
export async function readUriAsArrayBuffer(uri: string) {
|
||||
const response = await fetch(uri)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("The selected image could not be read.")
|
||||
}
|
||||
|
||||
return response.arrayBuffer()
|
||||
}
|
||||
|
||||
function buildActions(width: number, height: number) {
|
||||
const largestSide = Math.max(width, height)
|
||||
|
||||
if (largestSide <= 1600) {
|
||||
return []
|
||||
}
|
||||
|
||||
const scale = 1600 / largestSide
|
||||
|
||||
return [
|
||||
{
|
||||
resize: {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function prepareNoteImage(image: StagedNoteImage): Promise<PreparedNoteImage> {
|
||||
const normalizedMimeType = validateStagedNoteImage(image)
|
||||
const targetMimeType = normalizedMimeType === PNG_MIME ? PNG_MIME : JPEG_MIME
|
||||
const saveFormat = getSaveFormat(targetMimeType)
|
||||
const actions =
|
||||
image.width && image.height
|
||||
? buildActions(image.width, image.height)
|
||||
: []
|
||||
|
||||
const compressions = targetMimeType === PNG_MIME ? [1] : [0.82, 0.7, 0.56, 0.42, 0.32]
|
||||
|
||||
for (const compression of compressions) {
|
||||
const result = await ImageManipulator.manipulateAsync(image.uri, actions, {
|
||||
compress: compression,
|
||||
format: saveFormat,
|
||||
})
|
||||
const sizeBytes = await getFileSize(result.uri)
|
||||
|
||||
if (sizeBytes <= MAX_NOTE_IMAGE_BYTES) {
|
||||
return {
|
||||
uri: result.uri,
|
||||
mimeType: targetMimeType,
|
||||
sizeBytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Image too large. The selected image is still larger than 15 MB after compression.")
|
||||
}
|
||||
|
||||
export function createStoragePath(userId: string, mimeType: SupportedMimeType) {
|
||||
const id =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
return `${userId}/${id}.${getFileExtension(mimeType)}`
|
||||
}
|
||||
79
FastNotes/src/notes/native-image-picker.ts
Normal file
79
FastNotes/src/notes/native-image-picker.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as ImagePicker from "expo-image-picker"
|
||||
import { Platform } from "react-native"
|
||||
|
||||
import { StagedNoteImage } from "@/src/notes/image-utils"
|
||||
|
||||
function buildStagedImage(asset: ImagePicker.ImagePickerAsset): StagedNoteImage {
|
||||
return {
|
||||
uri: asset.uri,
|
||||
fileName: asset.fileName ?? `note-image-${Date.now()}.jpg`,
|
||||
mimeType: asset.mimeType ?? null,
|
||||
fileSize: asset.fileSize ?? null,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedPlatformError(action: "camera" | "gallery") {
|
||||
return Platform.OS === "web"
|
||||
? `Native ${action} support is only enabled on iOS and Android in this app.`
|
||||
: `This device cannot open the ${action}.`
|
||||
}
|
||||
|
||||
export async function pickImageFromLibrary() {
|
||||
if (Platform.OS === "web") {
|
||||
throw new Error(unsupportedPlatformError("gallery"))
|
||||
}
|
||||
|
||||
const permission = await ImagePicker.getMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!permission.granted) {
|
||||
const requested = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!requested.granted) {
|
||||
throw new Error("Photo library access is required to choose an image.")
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
allowsEditing: false,
|
||||
mediaTypes: ["images"],
|
||||
quality: 1,
|
||||
selectionLimit: 1,
|
||||
})
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildStagedImage(result.assets[0])
|
||||
}
|
||||
|
||||
export async function pickImageFromCamera() {
|
||||
if (Platform.OS === "web") {
|
||||
throw new Error(unsupportedPlatformError("camera"))
|
||||
}
|
||||
|
||||
const permission = await ImagePicker.getCameraPermissionsAsync()
|
||||
|
||||
if (!permission.granted) {
|
||||
const requested = await ImagePicker.requestCameraPermissionsAsync()
|
||||
|
||||
if (!requested.granted) {
|
||||
throw new Error("Camera access is required to take a photo.")
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: false,
|
||||
cameraType: ImagePicker.CameraType.back,
|
||||
mediaTypes: ["images"],
|
||||
quality: 1,
|
||||
})
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildStagedImage(result.assets[0])
|
||||
}
|
||||
153
FastNotes/src/notes/note-image-storage.ts
Normal file
153
FastNotes/src/notes/note-image-storage.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { supabase, supabaseAnonKey, supabaseUrl } from "@/libs/supabase"
|
||||
import {
|
||||
createStoragePath,
|
||||
NOTE_IMAGE_BUCKET,
|
||||
prepareNoteImage,
|
||||
StagedNoteImage,
|
||||
UploadedNoteImage,
|
||||
} from "@/src/notes/image-utils"
|
||||
|
||||
export type NoteImageUploadProgress = {
|
||||
loaded: number
|
||||
total: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
type UploadNoteImageOptions = {
|
||||
onProgress?: (progress: NoteImageUploadProgress) => void
|
||||
}
|
||||
|
||||
function normalizeUploadFailureMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.startsWith("Unsupported image format.") ||
|
||||
error.message.startsWith("Image too large.")
|
||||
) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error.message.trim()) {
|
||||
return `Image upload failed. ${error.message}`
|
||||
}
|
||||
}
|
||||
|
||||
return "Image upload failed. Check your connection and try again."
|
||||
}
|
||||
|
||||
async function uploadWithProgress(
|
||||
path: string,
|
||||
fileUri: string,
|
||||
mimeType: string,
|
||||
onProgress?: (progress: NoteImageUploadProgress) => void
|
||||
) {
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error("Image upload failed. Storage configuration is missing.")
|
||||
}
|
||||
|
||||
const anonKey = supabaseAnonKey
|
||||
const storageBaseUrl = supabaseUrl
|
||||
const session = await supabase.auth.getSession()
|
||||
const accessToken = session.data.session?.access_token
|
||||
const uploadUrl = `${storageBaseUrl}/storage/v1/object/${NOTE_IMAGE_BUCKET}/${path}`
|
||||
|
||||
const response = await fetch(fileUri)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("The selected image could not be read.")
|
||||
}
|
||||
|
||||
const fileBlob = await response.blob()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.open("POST", uploadUrl)
|
||||
xhr.setRequestHeader("apikey", anonKey)
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${accessToken ?? anonKey}`)
|
||||
xhr.setRequestHeader("x-upsert", "false")
|
||||
xhr.setRequestHeader("cache-control", "3600")
|
||||
xhr.setRequestHeader("content-type", mimeType)
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable || !event.total) {
|
||||
return
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
progress: Math.min(100, Math.round((event.loaded / event.total) * 100)),
|
||||
})
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
onProgress?.({
|
||||
loaded: fileBlob.size,
|
||||
total: fileBlob.size,
|
||||
progress: 100,
|
||||
})
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(xhr.responseText) as { message?: string; error?: string }
|
||||
reject(new Error(parsed.message || parsed.error || "Upload failed."))
|
||||
} catch {
|
||||
reject(new Error("Upload failed."))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error("Check your connection and try again."))
|
||||
}
|
||||
|
||||
xhr.onabort = () => {
|
||||
reject(new Error("Upload cancelled."))
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
loaded: 0,
|
||||
total: fileBlob.size,
|
||||
progress: 0,
|
||||
})
|
||||
xhr.send(fileBlob)
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadNoteImage(
|
||||
userId: string,
|
||||
image: StagedNoteImage,
|
||||
options: UploadNoteImageOptions = {}
|
||||
): Promise<UploadedNoteImage> {
|
||||
const preparedImage = await prepareNoteImage(image)
|
||||
const path = createStoragePath(userId, preparedImage.mimeType)
|
||||
|
||||
try {
|
||||
await uploadWithProgress(path, preparedImage.uri, preparedImage.mimeType, options.onProgress)
|
||||
} catch (error) {
|
||||
throw new Error(normalizeUploadFailureMessage(error))
|
||||
}
|
||||
|
||||
const { data } = supabase.storage
|
||||
.from(NOTE_IMAGE_BUCKET)
|
||||
.getPublicUrl(path)
|
||||
|
||||
return {
|
||||
path,
|
||||
publicUrl: data.publicUrl,
|
||||
mimeType: preparedImage.mimeType,
|
||||
sizeBytes: preparedImage.sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNoteImage(path: string) {
|
||||
const { error } = await supabase.storage
|
||||
.from(NOTE_IMAGE_BUCKET)
|
||||
.remove([path])
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
}
|
||||
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal file
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { PropsWithChildren, useEffect } from "react"
|
||||
import * as Notifications from "expo-notifications"
|
||||
|
||||
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||
import { supabase } from "@/libs/supabase"
|
||||
import { registerPushNotifications } from "@/src/notifications/push-notifications"
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
})
|
||||
|
||||
async function loadCreatorEmail(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("email")
|
||||
.eq("id", userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to load note creator email:", error.message)
|
||||
return "unknown user"
|
||||
}
|
||||
|
||||
return typeof data?.email === "string" && data.email.trim() ? data.email : "unknown user"
|
||||
}
|
||||
|
||||
export default function PushNotificationsProvider({ children }: PropsWithChildren) {
|
||||
const { claims, isLoading, isLoggedIn } = useAuthContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isLoggedIn || !claims?.sub) {
|
||||
return
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
let teardownRealtimeFallback: (() => void) | undefined
|
||||
|
||||
const register = async () => {
|
||||
const result = await registerPushNotifications(claims.sub as string)
|
||||
|
||||
if (isCancelled || result.status === "registered" || result.status === "denied" || result.status === "unsupported") {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === "local-only" || result.status === "missing-project-id" || result.status === "error") {
|
||||
const channel = supabase
|
||||
.channel(`notes-local-notifications-${claims.sub}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "INSERT",
|
||||
schema: "public",
|
||||
table: "Notes",
|
||||
},
|
||||
async (payload) => {
|
||||
const note = payload.new as { id?: string | number; title?: string; created_by?: string }
|
||||
|
||||
if (!note?.created_by || note.created_by === claims.sub) {
|
||||
return
|
||||
}
|
||||
|
||||
const creatorEmail = await loadCreatorEmail(note.created_by)
|
||||
|
||||
void Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: "FastNotes",
|
||||
body: `New note: "${note.title ?? "Untitled"}" by ${creatorEmail}`,
|
||||
data: {
|
||||
type: "new-note",
|
||||
noteId: note.id ? String(note.id) : "",
|
||||
title: note.title ?? "Untitled",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
teardownRealtimeFallback = () => {
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "missing-project-id" || result.status === "error") {
|
||||
console.error("Push notification setup failed:", result.message)
|
||||
}
|
||||
}
|
||||
|
||||
void register()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
teardownRealtimeFallback?.()
|
||||
}
|
||||
}, [claims?.sub, isLoading, isLoggedIn])
|
||||
|
||||
return children
|
||||
}
|
||||
134
FastNotes/src/notifications/push-notifications.ts
Normal file
134
FastNotes/src/notifications/push-notifications.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||
import Constants from "expo-constants"
|
||||
import * as Notifications from "expo-notifications"
|
||||
import { Platform } from "react-native"
|
||||
|
||||
import { supabase } from "@/libs/supabase"
|
||||
|
||||
const INSTALLATION_ID_STORAGE_KEY = "fastnotes.push.installation-id"
|
||||
const PUSH_TOKEN_TABLE = "user_push_tokens"
|
||||
|
||||
type PushRegistrationResult =
|
||||
| { status: "unsupported" | "denied" | "registered" | "local-only" }
|
||||
| { status: "missing-project-id" | "error"; message: string }
|
||||
|
||||
function getEasProjectId() {
|
||||
return (
|
||||
Constants.easConfig?.projectId ??
|
||||
Constants.expoConfig?.extra?.easProjectId ??
|
||||
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function getInstallationId() {
|
||||
const existingInstallationId = await AsyncStorage.getItem(INSTALLATION_ID_STORAGE_KEY)
|
||||
|
||||
if (existingInstallationId) {
|
||||
return existingInstallationId
|
||||
}
|
||||
|
||||
const nextInstallationId = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
await AsyncStorage.setItem(INSTALLATION_ID_STORAGE_KEY, nextInstallationId)
|
||||
return nextInstallationId
|
||||
}
|
||||
|
||||
async function ensureAndroidChannel() {
|
||||
if (Platform.OS !== "android") {
|
||||
return
|
||||
}
|
||||
|
||||
await Notifications.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#1f6feb",
|
||||
})
|
||||
}
|
||||
|
||||
function isPhysicalDevice() {
|
||||
return Constants.isDevice ?? false
|
||||
}
|
||||
|
||||
export async function registerPushNotifications(userId: string): Promise<PushRegistrationResult> {
|
||||
if (Platform.OS === "web") {
|
||||
return { status: "unsupported" }
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAndroidChannel()
|
||||
|
||||
const existingPermissions = await Notifications.getPermissionsAsync()
|
||||
let finalStatus = existingPermissions.status
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
const requestedPermissions = await Notifications.requestPermissionsAsync()
|
||||
finalStatus = requestedPermissions.status
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
return { status: "denied" }
|
||||
}
|
||||
|
||||
if (!isPhysicalDevice()) {
|
||||
return { status: "local-only" }
|
||||
}
|
||||
|
||||
const projectId = getEasProjectId()
|
||||
|
||||
if (!projectId) {
|
||||
return {
|
||||
status: "missing-project-id",
|
||||
message: "Missing Expo EAS project ID. Set EXPO_PUBLIC_EAS_PROJECT_ID before building the app.",
|
||||
}
|
||||
}
|
||||
|
||||
const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId })
|
||||
const installationId = await getInstallationId()
|
||||
|
||||
const { error } = await supabase.from(PUSH_TOKEN_TABLE).upsert(
|
||||
{
|
||||
installation_id: installationId,
|
||||
user_id: userId,
|
||||
push_token: expoPushToken.data,
|
||||
platform: Platform.OS,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: "installation_id",
|
||||
}
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return { status: "error", message: error.message }
|
||||
}
|
||||
|
||||
return { status: "registered" }
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Push notification registration failed.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterPushNotifications(userId?: string) {
|
||||
if (Platform.OS === "web") {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const installationId = await getInstallationId()
|
||||
let query = supabase.from(PUSH_TOKEN_TABLE).delete().eq("installation_id", installationId)
|
||||
|
||||
if (userId) {
|
||||
query = query.eq("user_id", userId)
|
||||
}
|
||||
|
||||
const { error } = await query
|
||||
return !error
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
568
FastNotes/src/styles/app-styles.ts
Normal file
568
FastNotes/src/styles/app-styles.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { Appearance, StyleSheet } from "react-native"
|
||||
|
||||
export const uploadProgressBarStyles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
percentage: {
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
track: {
|
||||
height: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
},
|
||||
})
|
||||
|
||||
export const parallaxScrollViewStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: 250,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
})
|
||||
|
||||
export const themedTextStyles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "600",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: "#0a7ea4",
|
||||
},
|
||||
})
|
||||
|
||||
export const loginScreenStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: "#c62828",
|
||||
fontSize: 14,
|
||||
},
|
||||
loginButton: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#696969",
|
||||
marginTop: 8,
|
||||
},
|
||||
loginButtonPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
loginButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
link: {
|
||||
alignSelf: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
linkText: {
|
||||
color: "#0b57d0",
|
||||
fontSize: 16,
|
||||
},
|
||||
})
|
||||
|
||||
export const signupScreenStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "700",
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: "#c62828",
|
||||
fontSize: 14,
|
||||
},
|
||||
successText: {
|
||||
color: "#2e7d32",
|
||||
fontSize: 14,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
backgroundColor: "#111",
|
||||
marginTop: 8,
|
||||
},
|
||||
actionButtonPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
actionButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
actionButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
link: {
|
||||
alignSelf: "center",
|
||||
marginTop: 8,
|
||||
},
|
||||
linkText: {
|
||||
color: "#0b57d0",
|
||||
fontSize: 16,
|
||||
},
|
||||
})
|
||||
|
||||
export const signOutButtonStyles = StyleSheet.create({
|
||||
button: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
})
|
||||
|
||||
export const collapsibleStyles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
})
|
||||
|
||||
export const homeScreenStyles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
topBar: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: "700",
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 12,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
tabButtonActive: {
|
||||
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#8a8888",
|
||||
},
|
||||
tabButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
tabButtonTextActive: {
|
||||
color: "#fff",
|
||||
},
|
||||
list: { padding: 16, gap: 12, paddingTop: 8 },
|
||||
noteItem: { padding: 16, borderWidth: 1, borderRadius: 12, gap: 8 },
|
||||
noteCardRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
gap: 12,
|
||||
},
|
||||
noteBody: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
noteTitle: { fontSize: 16, fontWeight: "600" },
|
||||
noteThumbnailFrame: {
|
||||
width: 110,
|
||||
height: 110,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
noteThumbnail: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
notePreview: { fontSize: 14 },
|
||||
noteMeta: { fontSize: 12 },
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
paddingVertical: 32,
|
||||
color: "#666",
|
||||
},
|
||||
errorText: {
|
||||
color: "#c62828",
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
fab: {
|
||||
position: "absolute",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700" },
|
||||
})
|
||||
|
||||
export const noteImagePanelStyles = StyleSheet.create({
|
||||
section: {
|
||||
gap: 12,
|
||||
},
|
||||
previewCard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
},
|
||||
previewLayout: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
alignItems: "stretch",
|
||||
},
|
||||
previewLayoutStacked: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
previewDetails: {
|
||||
flex: 0.95,
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
},
|
||||
previewFrame: {
|
||||
flex: 1.7,
|
||||
minHeight: 220,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
},
|
||||
previewImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
previewMetaLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: "700",
|
||||
},
|
||||
previewMeta: {
|
||||
fontSize: 13,
|
||||
},
|
||||
urlText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
buttonRow: {
|
||||
gap: 10,
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
enabledButtonShadow: {
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.14,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.45,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
},
|
||||
secondaryButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
removeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
fullscreenOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.82)",
|
||||
},
|
||||
fullscreenBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
fullscreenCard: {
|
||||
width: "100%",
|
||||
maxWidth: 900,
|
||||
height: "82%",
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
},
|
||||
closeButton: {
|
||||
alignSelf: "flex-end",
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
fullscreenImage: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
borderRadius: 12,
|
||||
backgroundColor: "#d7d7d7",
|
||||
},
|
||||
})
|
||||
|
||||
export const newNoteScreenStyles = StyleSheet.create({
|
||||
keyboardAvoider: { flex: 1 },
|
||||
container: { flex: 1 },
|
||||
formContent: { padding: 16, gap: 12 },
|
||||
titleInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
},
|
||||
contentInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
actions: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
actionsBlur: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: "hidden",
|
||||
},
|
||||
actionsContent: {
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 10,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
enabledButtonShadow: {
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.45,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
saveFloatingText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
errorText: {
|
||||
color: "#c62828",
|
||||
},
|
||||
})
|
||||
|
||||
export const detailScreenStyles = StyleSheet.create({
|
||||
keyboardAvoider: { flex: 1 },
|
||||
container: { flex: 1 },
|
||||
formContent: { padding: 16, gap: 12 },
|
||||
title: { fontSize: 22, fontWeight: "700" },
|
||||
content: { fontSize: 16 },
|
||||
titleInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
},
|
||||
signature: {
|
||||
fontSize: 12,
|
||||
color: "#666",
|
||||
},
|
||||
contentInput: {
|
||||
minHeight: 200,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: "#c62828",
|
||||
},
|
||||
successText: {
|
||||
color: "#2e7d32",
|
||||
},
|
||||
readOnlyText: {
|
||||
color: "#666",
|
||||
},
|
||||
actions: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
actionsBlur: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: "hidden",
|
||||
},
|
||||
actionsContent: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 10,
|
||||
},
|
||||
primaryButton: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
enabledButtonShadow: {
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
deleteButton: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.45,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
})
|
||||
@@ -14,8 +14,8 @@ const lightPalette = {
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
background: "#000000",
|
||||
surface: "#0b0b0b",
|
||||
background: "#191919",
|
||||
surface: "#343232",
|
||||
elevated: "#111111",
|
||||
text: "#ffffff",
|
||||
mutedText: "#b8b8b8",
|
||||
|
||||
Reference in New Issue
Block a user