feat: Modernize UI with Tailwind CSS v4 + Framer Motion
- Add Tailwind CSS v4 with custom Sociowire theme - Add Framer Motion for smooth animations - Create Skeleton component for loading states - Create BlurImage component with progressive blur-up effect - Create new PostCardNew with: - Entry/hover/tap animations - Glassmorphism design - Category-colored badges - Micro-interactions on buttons - Update PostList to use new components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
197151d818
commit
8f2e5b8c18
|
|
@ -10,6 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.27.0",
|
||||||
"i18next": "^25.7.4",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
|
@ -23,16 +25,33 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
@ -1362,6 +1381,277 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/node": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
|
"enhanced-resolve": "^5.18.3",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"lightningcss": "1.30.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"source-map-js": "^1.2.1",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||||
|
"bundleDependencies": [
|
||||||
|
"@napi-rs/wasm-runtime",
|
||||||
|
"@emnapi/core",
|
||||||
|
"@emnapi/runtime",
|
||||||
|
"@tybys/wasm-util",
|
||||||
|
"@emnapi/wasi-threads",
|
||||||
|
"tslib"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1",
|
||||||
|
"@emnapi/wasi-threads": "^1.1.0",
|
||||||
|
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||||
|
"@tybys/wasm-util": "^0.10.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/postcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
|
"@tailwindcss/node": "4.1.18",
|
||||||
|
"@tailwindcss/oxide": "4.1.18",
|
||||||
|
"postcss": "^8.4.41",
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1543,6 +1833,43 @@
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/autoprefixer": {
|
||||||
|
"version": "10.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
|
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browserslist": "^4.28.1",
|
||||||
|
"caniuse-lite": "^1.0.30001760",
|
||||||
|
"fraction.js": "^5.3.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"postcss-value-parser": "^4.2.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"autoprefixer": "bin/autoprefixer"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"postcss": "^8.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
|
|
@ -1668,6 +1995,15 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -1780,6 +2116,16 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -1804,6 +2150,20 @@
|
||||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/enhanced-resolve": {
|
||||||
|
"version": "5.18.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
|
||||||
|
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"tapable": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
|
@ -2185,6 +2545,47 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fraction.js": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/rawify"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.0.tgz",
|
||||||
|
"integrity": "sha512-gJtqOKEDJH/jrn0PpsWp64gdOjBvGX8hY6TWstxjDot/85daIEtJHl1UsiwHSXiYmJF2QXUoXP6/3gGw5xY2YA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.27.0",
|
||||||
|
"motion-utils": "^12.24.10",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -2307,6 +2708,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
|
@ -2482,6 +2890,16 @@
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.1.3",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||||
|
|
@ -2587,6 +3005,267 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.16.1",
|
"version": "2.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.16.1.tgz",
|
||||||
|
|
@ -2649,6 +3328,16 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/maplibre-gl": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "5.15.0",
|
"version": "5.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.15.0.tgz",
|
||||||
|
|
@ -2733,6 +3422,21 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.0.tgz",
|
||||||
|
"integrity": "sha512-oDjl0WoAsWIWKl3GCDxmh7GITrNjmLX+w5+jwk4+pzLu3VnFvsOv2E6+xCXeH72O65xlXsr84/otiOYQKW/nQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.24.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.24.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
|
||||||
|
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -2906,6 +3610,7 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -2915,6 +3620,13 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss-value-parser": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/potpack": {
|
"node_modules/potpack": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
|
@ -3212,6 +3924,27 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tapable": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/webpack"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyqueue": {
|
"node_modules/tinyqueue": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.27.0",
|
||||||
"i18next": "^25.7.4",
|
"i18next": "^25.7.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
|
@ -25,13 +27,17 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,892 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { loadAuth } from "../../auth/authStorage";
|
||||||
|
import {
|
||||||
|
fetchPostLikeState,
|
||||||
|
recordPostShare,
|
||||||
|
togglePostLike,
|
||||||
|
updatePostVisibility,
|
||||||
|
fetchProfile,
|
||||||
|
editPost,
|
||||||
|
deletePost,
|
||||||
|
} from "../../api/client";
|
||||||
|
import { useAuth } from "../../auth/AuthContext";
|
||||||
|
import { trackEvent } from "../../utils/analytics";
|
||||||
|
import { BlurImage, GalleryImage } from "../ui/BlurImage";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Utilities
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isAuthed() {
|
||||||
|
try {
|
||||||
|
const auth = loadAuth();
|
||||||
|
return !!(auth && auth.access_token);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestAuth(message) {
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("sociowire:auth-required", {
|
||||||
|
detail: { message, mode: "login" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
trackEvent("auth_required", { context: "wall", mode: "login" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(createdAt) {
|
||||||
|
try {
|
||||||
|
if (!createdAt) return "now";
|
||||||
|
const d = createdAt instanceof Date ? createdAt : new Date(String(createdAt));
|
||||||
|
if (Number.isNaN(d.getTime())) return "now";
|
||||||
|
const s = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||||
|
if (s < 60) return "now";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 48) return `${h}h`;
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
return `${days}d`;
|
||||||
|
} catch {
|
||||||
|
return "now";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "0";
|
||||||
|
if (n < 1000) return String(Math.max(0, Math.floor(n)));
|
||||||
|
if (n < 1000000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return `${(n / 1000000).toFixed(1)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabel(post) {
|
||||||
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
||||||
|
if (c === "EVENT") return "EVENTS";
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryIconClass(post) {
|
||||||
|
const c = (post?.category || "NEWS").toString().toUpperCase();
|
||||||
|
switch (c) {
|
||||||
|
case "EVENT":
|
||||||
|
case "EVENTS":
|
||||||
|
return "fa-regular fa-calendar";
|
||||||
|
case "FRIENDS":
|
||||||
|
return "fa-solid fa-user-group";
|
||||||
|
case "MARKET":
|
||||||
|
return "fa-solid fa-store";
|
||||||
|
default:
|
||||||
|
return "fa-regular fa-newspaper";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryBadgeClass(category) {
|
||||||
|
const c = (category || "NEWS").toString().toUpperCase();
|
||||||
|
switch (c) {
|
||||||
|
case "BREAKING":
|
||||||
|
return "badge-breaking";
|
||||||
|
case "MARKET":
|
||||||
|
return "badge-market";
|
||||||
|
case "FRIENDS":
|
||||||
|
return "badge-friends";
|
||||||
|
case "EVENT":
|
||||||
|
case "EVENTS":
|
||||||
|
return "badge-events";
|
||||||
|
case "LIVE":
|
||||||
|
return "badge-live";
|
||||||
|
default:
|
||||||
|
return "badge-news";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageUrl(raw) {
|
||||||
|
const v = (raw || "").toString().trim();
|
||||||
|
if (!v) return "";
|
||||||
|
const lower = v.toLowerCase();
|
||||||
|
if (["null", "undefined", "none", "false", "0", "self", "default"].includes(lower)) return "";
|
||||||
|
if (lower.startsWith("data:")) return "";
|
||||||
|
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("//") || lower.startsWith("/") || lower.startsWith("./")) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar cache
|
||||||
|
const wallAvatarCache = new Map();
|
||||||
|
const wallAvatarInflight = new Map();
|
||||||
|
|
||||||
|
async function getAvatarForUser(name) {
|
||||||
|
const key = (name || "").toLowerCase().trim();
|
||||||
|
if (!key) return "";
|
||||||
|
if (wallAvatarCache.has(key)) return wallAvatarCache.get(key);
|
||||||
|
if (wallAvatarInflight.has(key)) return wallAvatarInflight.get(key);
|
||||||
|
const p = (async () => {
|
||||||
|
const profile = await fetchProfile(name);
|
||||||
|
const url = profile?.avatar_url ? String(profile.avatar_url) : "";
|
||||||
|
wallAvatarCache.set(key, url);
|
||||||
|
wallAvatarInflight.delete(key);
|
||||||
|
return url;
|
||||||
|
})().catch(() => {
|
||||||
|
wallAvatarCache.set(key, "");
|
||||||
|
wallAvatarInflight.delete(key);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
wallAvatarInflight.set(key, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Animation Variants
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20, scale: 0.98 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
y: -4,
|
||||||
|
scale: 1.005,
|
||||||
|
transition: { duration: 0.3, ease: "easeOut" },
|
||||||
|
},
|
||||||
|
tap: { scale: 0.995 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
hover: { scale: 1.05, y: -1 },
|
||||||
|
tap: { scale: 0.95 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statsVariants = {
|
||||||
|
initial: { scale: 1 },
|
||||||
|
pulse: {
|
||||||
|
scale: [1, 1.2, 1],
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Sub-components
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ActionButton({ icon, label, active, danger, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-2 px-4 py-2 rounded-full
|
||||||
|
text-sm font-semibold
|
||||||
|
transition-colors duration-200
|
||||||
|
${active
|
||||||
|
? "bg-gradient-to-r from-accent-dark/25 to-purple-500/20 border-accent/40 text-accent shadow-glow-sm"
|
||||||
|
: danger
|
||||||
|
? "bg-surface-800/60 border-red-500/30 text-red-400 hover:bg-red-500/15 hover:text-red-300"
|
||||||
|
: "bg-surface-800/60 border-surface-600/20 text-surface-300 hover:bg-accent/15 hover:border-accent/30 hover:text-accent"
|
||||||
|
}
|
||||||
|
border backdrop-blur-sm
|
||||||
|
`}
|
||||||
|
onClick={onClick}
|
||||||
|
variants={buttonVariants}
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
>
|
||||||
|
<i className={icon} />
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
{children}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ icon, count, onClick }) {
|
||||||
|
const [didPulse, setDidPulse] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.span
|
||||||
|
className="flex items-center gap-1.5 text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
|
||||||
|
onClick={onClick}
|
||||||
|
variants={statsVariants}
|
||||||
|
animate={didPulse ? "pulse" : "initial"}
|
||||||
|
onAnimationComplete={() => setDidPulse(false)}
|
||||||
|
>
|
||||||
|
<i className={icon} />
|
||||||
|
<span className="text-sm">{formatCount(count)}</span>
|
||||||
|
</motion.span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageFallback({ category, iconClass }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-64 flex items-center justify-center bg-gradient-to-br from-surface-800/95 via-surface-850/98 to-surface-900">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-accent/70">
|
||||||
|
<motion.i
|
||||||
|
className={`${iconClass} text-5xl opacity-60`}
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 0.6 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest opacity-70">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function PostCardNew({ post, selected, onSelect, onPostDeleted, onPostUpdated }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [authed, setAuthed] = useState(isAuthed());
|
||||||
|
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||||
|
const [failedImageIndices, setFailedImageIndices] = useState(() => new Set());
|
||||||
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
|
const [showPrivacy, setShowPrivacy] = useState(false);
|
||||||
|
const [privacySaving, setPrivacySaving] = useState(false);
|
||||||
|
const [privacyError, setPrivacyError] = useState("");
|
||||||
|
const [postVisibility, setPostVisibility] = useState((post?.visibility || "public").toString());
|
||||||
|
const [postGroupId, setPostGroupId] = useState((post?.group_id || "").toString());
|
||||||
|
const [postUsers, setPostUsers] = useState("");
|
||||||
|
const [applyToImages, setApplyToImages] = useState(true);
|
||||||
|
const [counts, setCounts] = useState(() => ({
|
||||||
|
like: post?.like_count ?? 0,
|
||||||
|
comment: post?.comment_count ?? 0,
|
||||||
|
share: post?.share_count ?? 0,
|
||||||
|
}));
|
||||||
|
const { username, token, userId } = useAuth();
|
||||||
|
|
||||||
|
// Edit/Delete state
|
||||||
|
const [showEditDelete, setShowEditDelete] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editTitle, setEditTitle] = useState("");
|
||||||
|
const [editSnippet, setEditSnippet] = useState("");
|
||||||
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const postId = post?.id ?? post?._id ?? 0;
|
||||||
|
|
||||||
|
// Fetch like state
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!authed || !postId) return () => {};
|
||||||
|
fetchPostLikeState(postId).then((res) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (res?.ok && res.liked) setLiked(true);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [authed, postId]);
|
||||||
|
|
||||||
|
// Auth change listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const next = isAuthed();
|
||||||
|
setAuthed(next);
|
||||||
|
if (!next) setLiked(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("sociowire:auth-changed", handler);
|
||||||
|
return () => window.removeEventListener("sociowire:auth-changed", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync counts
|
||||||
|
useEffect(() => {
|
||||||
|
setCounts({
|
||||||
|
like: post?.like_count ?? 0,
|
||||||
|
comment: post?.comment_count ?? 0,
|
||||||
|
share: post?.share_count ?? 0,
|
||||||
|
});
|
||||||
|
}, [post?.like_count, post?.comment_count, post?.share_count]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPostVisibility((post?.visibility || "public").toString());
|
||||||
|
setPostGroupId((post?.group_id || "").toString());
|
||||||
|
}, [post?.visibility, post?.group_id]);
|
||||||
|
|
||||||
|
// Media URLs
|
||||||
|
const mediaUrls = useMemo(() => {
|
||||||
|
const list = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const addUrl = (value) => {
|
||||||
|
const normalized = normalizeImageUrl(value);
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
list.push(normalized);
|
||||||
|
};
|
||||||
|
if (Array.isArray(post?.media_urls)) {
|
||||||
|
post.media_urls.forEach(addUrl);
|
||||||
|
}
|
||||||
|
[post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url].forEach(addUrl);
|
||||||
|
return list;
|
||||||
|
}, [post?.media_urls, post?.image_large, post?.image_medium, post?.image_small, post?.image, post?.media_url]);
|
||||||
|
|
||||||
|
const mediaKey = mediaUrls.join("|");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveImageIndex(0);
|
||||||
|
setFailedImageIndices(new Set());
|
||||||
|
setImgFailed(false);
|
||||||
|
}, [mediaKey]);
|
||||||
|
|
||||||
|
const title = post?.title || "Untitled";
|
||||||
|
const snippet = post?.snippet || "";
|
||||||
|
const author = (post?.author || post?.Author || "anon").toString();
|
||||||
|
const authorId = post?.author_id || post?.authorId || null;
|
||||||
|
const isAuthor =
|
||||||
|
(username && author && author.toLowerCase() === username.toLowerCase()) ||
|
||||||
|
(userId && authorId && Number(userId) === Number(authorId));
|
||||||
|
const [authorAvatar, setAuthorAvatar] = useState("");
|
||||||
|
const sub = (post?.sub_category || post?.subCategory || "").toString();
|
||||||
|
const contentType = (post?.content_type || "").toString().toLowerCase();
|
||||||
|
const isLiveContent = contentType === "camera" || contentType === "live" || contentType === "stream";
|
||||||
|
const timeAgo = isLiveContent ? "LIVE" : formatRelativeTime(post?.created_at || post?.CreatedAt || post?.createdAt);
|
||||||
|
const cat = categoryLabel(post);
|
||||||
|
const badgeClass = getCategoryBadgeClass(cat);
|
||||||
|
|
||||||
|
const metaLine = useMemo(() => {
|
||||||
|
const parts = [];
|
||||||
|
if (sub) parts.push(sub);
|
||||||
|
if (post?.region) parts.push(post.region);
|
||||||
|
return parts.join(" • ");
|
||||||
|
}, [sub, post?.region]);
|
||||||
|
|
||||||
|
const safeImageIndex = mediaUrls.length ? Math.min(Math.max(activeImageIndex, 0), mediaUrls.length - 1) : 0;
|
||||||
|
const activeImage = mediaUrls[safeImageIndex] || "";
|
||||||
|
const hasGalleryImage = Boolean(activeImage && !imgFailed);
|
||||||
|
|
||||||
|
const findNextAvailableIndex = (current, failed) => {
|
||||||
|
if (mediaUrls.length === 0) return -1;
|
||||||
|
for (let offset = 1; offset <= mediaUrls.length; offset++) {
|
||||||
|
const idx = (current + offset) % mediaUrls.length;
|
||||||
|
if (!failed.has(idx)) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findPrevAvailableIndex = (current, failed) => {
|
||||||
|
if (mediaUrls.length === 0) return -1;
|
||||||
|
for (let offset = 1; offset <= mediaUrls.length; offset++) {
|
||||||
|
const idx = (current - offset + mediaUrls.length) % mediaUrls.length;
|
||||||
|
if (!failed.has(idx)) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
if (!activeImage) {
|
||||||
|
setImgFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextFailed = new Set(failedImageIndices);
|
||||||
|
nextFailed.add(safeImageIndex);
|
||||||
|
setFailedImageIndices(nextFailed);
|
||||||
|
if (nextFailed.size >= mediaUrls.length) {
|
||||||
|
setImgFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = findNextAvailableIndex(safeImageIndex, nextFailed);
|
||||||
|
if (nextIndex >= 0) setActiveImageIndex(nextIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch author avatar
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const who = (author || "").trim();
|
||||||
|
if (!who) {
|
||||||
|
setAuthorAvatar("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getAvatarForUser(who).then((url) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthorAvatar(url || "");
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [author]);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Handlers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleLike = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!authed) {
|
||||||
|
requestAuth(t("post.signInToLike"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEvent("post_like", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
|
||||||
|
const res = await togglePostLike(postId, !liked);
|
||||||
|
if (res?.ok) {
|
||||||
|
setLiked(!!res.liked);
|
||||||
|
if (res?.counts) {
|
||||||
|
setCounts({
|
||||||
|
like: res.counts.like_count,
|
||||||
|
comment: res.counts.comment_count,
|
||||||
|
share: res.counts.share_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComment = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!authed) {
|
||||||
|
requestAuth(t("post.signInToComment"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackEvent("post_comment", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
|
||||||
|
if (onSelect) onSelect(post);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const shareUrl = `${window.location.origin}/p/${postId}`;
|
||||||
|
trackEvent("post_share", { post_id: postId, post_title: title?.slice(0, 100), post_category: cat });
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: title || "SocioWire", url: shareUrl });
|
||||||
|
} else if (navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const res = await recordPostShare(postId);
|
||||||
|
if (res?.counts) {
|
||||||
|
setCounts({
|
||||||
|
like: res.counts.like_count,
|
||||||
|
comment: res.counts.comment_count,
|
||||||
|
share: res.counts.share_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePrivacy = async () => {
|
||||||
|
setPrivacyError("");
|
||||||
|
setPrivacySaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
visibility: postVisibility,
|
||||||
|
group_id: postGroupId.trim(),
|
||||||
|
users: postUsers.split(",").map((u) => u.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
if (applyToImages) {
|
||||||
|
payload.image_visibility = postVisibility;
|
||||||
|
payload.image_group_id = postGroupId.trim();
|
||||||
|
payload.image_users = payload.users;
|
||||||
|
}
|
||||||
|
await updatePostVisibility(postId, payload, token);
|
||||||
|
setPrivacySaving(false);
|
||||||
|
} catch {
|
||||||
|
setPrivacyError(t("post.visibilityError"));
|
||||||
|
setPrivacySaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
setEditSaving(true);
|
||||||
|
const res = await editPost(postId, { title: editTitle.trim(), snippet: editSnippet.trim() }, token);
|
||||||
|
setEditSaving(false);
|
||||||
|
if (res?.ok) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setShowEditDelete(false);
|
||||||
|
if (onPostUpdated) onPostUpdated(res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
const res = await deletePost(postId, token);
|
||||||
|
setDeleting(false);
|
||||||
|
if (res?.ok) {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setShowEditDelete(false);
|
||||||
|
if (onPostDeleted) onPostDeleted(postId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Render
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.article
|
||||||
|
className={`
|
||||||
|
group relative overflow-hidden rounded-2xl cursor-pointer
|
||||||
|
bg-gradient-to-br from-glass-card to-surface-800/95
|
||||||
|
backdrop-blur-xl
|
||||||
|
border border-white/10
|
||||||
|
shadow-card
|
||||||
|
transition-shadow duration-300
|
||||||
|
${selected ? "ring-2 ring-accent/50 border-accent/30" : ""}
|
||||||
|
`}
|
||||||
|
variants={cardVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
whileHover="hover"
|
||||||
|
whileTap="tap"
|
||||||
|
onClick={() => {
|
||||||
|
trackEvent("post_view", {
|
||||||
|
post_id: postId,
|
||||||
|
post_title: title?.slice(0, 100),
|
||||||
|
post_category: cat,
|
||||||
|
post_author: author,
|
||||||
|
content_type: "post",
|
||||||
|
});
|
||||||
|
onSelect && onSelect(post);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Header
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
{/* Avatar */}
|
||||||
|
<motion.div
|
||||||
|
className="relative w-12 h-12 rounded-full p-0.5 bg-gradient-to-br from-accent-dark via-purple-500 to-pink-500 shadow-glow-sm"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
{authorAvatar ? (
|
||||||
|
<img
|
||||||
|
src={authorAvatar}
|
||||||
|
alt={author}
|
||||||
|
className="w-full h-full rounded-full object-cover bg-surface-900"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
{author ? author[0]?.toUpperCase() : "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<motion.div
|
||||||
|
className="font-semibold text-surface-100 truncate group-hover:text-accent-light transition-colors"
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</motion.div>
|
||||||
|
<div className={`flex items-center gap-2 text-sm ${isLiveContent ? "text-red-400 font-bold" : "text-surface-500"}`}>
|
||||||
|
{isLiveContent && <span className="live-dot" />}
|
||||||
|
<span>{timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
<motion.div
|
||||||
|
className={`badge ${badgeClass}`}
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Image/Gallery
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
{hasGalleryImage ? (
|
||||||
|
<GalleryImage
|
||||||
|
src={activeImage}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-64 sm:h-72"
|
||||||
|
currentIndex={safeImageIndex}
|
||||||
|
totalCount={mediaUrls.length}
|
||||||
|
onPrev={() => {
|
||||||
|
const prev = findPrevAvailableIndex(safeImageIndex, failedImageIndices);
|
||||||
|
if (prev >= 0) setActiveImageIndex(prev);
|
||||||
|
}}
|
||||||
|
onNext={() => {
|
||||||
|
const next = findNextAvailableIndex(safeImageIndex, failedImageIndices);
|
||||||
|
if (next >= 0) setActiveImageIndex(next);
|
||||||
|
}}
|
||||||
|
onError={handleImageError}
|
||||||
|
fallback={<ImageFallback category={cat} iconClass={categoryIconClass(post)} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImageFallback category={cat} iconClass={categoryIconClass(post)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{mediaUrls.length > 1 && (
|
||||||
|
<motion.div
|
||||||
|
className="flex gap-2 p-3 bg-surface-900/50 overflow-x-auto"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
{mediaUrls.map((url, idx) => (
|
||||||
|
<motion.button
|
||||||
|
key={`thumb-${idx}`}
|
||||||
|
type="button"
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 w-14 h-14 rounded-xl overflow-hidden
|
||||||
|
border-2 transition-all duration-200
|
||||||
|
${idx === safeImageIndex
|
||||||
|
? "border-accent shadow-glow-sm"
|
||||||
|
: "border-transparent hover:border-accent/40"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveImageIndex(idx);
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Body
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-lg font-bold text-surface-50 line-clamp-2 mb-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{snippet && (
|
||||||
|
<p className="text-surface-400 text-sm leading-relaxed line-clamp-3 mb-2">
|
||||||
|
{snippet}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{metaLine && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-surface-500">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-gradient-to-r from-accent to-purple-400" />
|
||||||
|
<span>{metaLine}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Footer
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between gap-4 p-4 border-t border-white/5 bg-surface-900/30">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<StatItem icon="fa-regular fa-heart" count={counts.like} />
|
||||||
|
<StatItem icon="fa-regular fa-comment" count={counts.comment} />
|
||||||
|
<StatItem icon="fa-solid fa-share-nodes" count={counts.share} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 flex-wrap justify-end">
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-regular fa-heart"
|
||||||
|
label={t("post.like")}
|
||||||
|
active={liked}
|
||||||
|
onClick={handleLike}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-regular fa-comment"
|
||||||
|
label={t("post.comment")}
|
||||||
|
onClick={handleComment}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-share-nodes"
|
||||||
|
label={t("post.share")}
|
||||||
|
onClick={handleShare}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAuthor && (
|
||||||
|
<>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-lock"
|
||||||
|
active={showPrivacy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPrivacy((prev) => !prev);
|
||||||
|
setShowEditDelete(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-ellipsis"
|
||||||
|
active={showEditDelete}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowEditDelete((prev) => !prev);
|
||||||
|
setShowPrivacy(false);
|
||||||
|
if (!showEditDelete) {
|
||||||
|
setEditTitle(post?.title || "");
|
||||||
|
setEditSnippet(post?.snippet || "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Privacy Panel
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAuthor && showPrivacy && (
|
||||||
|
<motion.div
|
||||||
|
className="mx-4 mb-4 p-4 rounded-2xl bg-surface-800/80 border border-surface-600/20"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<label className="text-surface-300 whitespace-nowrap font-medium">{t("common.post")}</label>
|
||||||
|
<select
|
||||||
|
className="flex-1 bg-surface-900/80 border border-surface-600/30 rounded-xl px-3 py-2 text-surface-200 text-sm focus:outline-none focus:border-accent/50"
|
||||||
|
value={postVisibility}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setPostVisibility(next);
|
||||||
|
if (next !== "group") setPostGroupId("");
|
||||||
|
if (next !== "custom") setPostUsers("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="public">{t("post.public")}</option>
|
||||||
|
<option value="friends">{t("post.friendsOnly")}</option>
|
||||||
|
<option value="private">{t("post.private")}</option>
|
||||||
|
<option value="group">{t("post.group")}</option>
|
||||||
|
<option value="custom">{t("post.custom")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{postVisibility === "group" && (
|
||||||
|
<input
|
||||||
|
className="w-full bg-surface-900/80 border border-surface-600/30 rounded-xl px-3 py-2 text-surface-200 text-sm focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder={t("post.groupId")}
|
||||||
|
value={postGroupId}
|
||||||
|
onChange={(e) => setPostGroupId(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{postVisibility === "custom" && (
|
||||||
|
<input
|
||||||
|
className="w-full bg-surface-900/80 border border-surface-600/30 rounded-xl px-3 py-2 text-surface-200 text-sm focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder={t("post.usernamesComma")}
|
||||||
|
value={postUsers}
|
||||||
|
onChange={(e) => setPostUsers(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-surface-400 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={applyToImages}
|
||||||
|
onChange={(e) => setApplyToImages(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
{t("post.applyToImages")}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{privacyError && (
|
||||||
|
<div className="text-xs text-red-400 bg-red-500/10 rounded-lg px-3 py-2">
|
||||||
|
{privacyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActionButton
|
||||||
|
icon={privacySaving ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-check"}
|
||||||
|
label={privacySaving ? t("post.saving") : t("common.save")}
|
||||||
|
onClick={handleSavePrivacy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Edit/Delete Panel
|
||||||
|
───────────────────────────────────────────────────────────────────────── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAuthor && showEditDelete && (
|
||||||
|
<motion.div
|
||||||
|
className="mx-4 mb-4 p-4 rounded-2xl bg-surface-800/80 border border-surface-600/20"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!isEditing && !showDeleteConfirm && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-pen"
|
||||||
|
label={t("common.edit")}
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-trash"
|
||||||
|
label={t("common.delete")}
|
||||||
|
danger
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-surface-900/80 border border-surface-600/30 rounded-xl px-4 py-3 text-surface-100 focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder={t("post.title")}
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-surface-900/80 border border-surface-600/30 rounded-xl px-4 py-3 text-surface-200 resize-y min-h-[80px] focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder={t("post.description")}
|
||||||
|
value={editSnippet}
|
||||||
|
onChange={(e) => setEditSnippet(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<ActionButton
|
||||||
|
icon={editSaving ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-check"}
|
||||||
|
label={editSaving ? t("post.saving") : t("common.save")}
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-xmark"
|
||||||
|
label={t("common.cancel")}
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-red-400 text-sm bg-red-500/10 rounded-xl px-4 py-3 text-center">
|
||||||
|
{t("post.deletePost")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<ActionButton
|
||||||
|
icon={deleting ? "fa-solid fa-spinner fa-spin" : "fa-solid fa-trash"}
|
||||||
|
label={deleting ? t("post.deleting") : t("common.delete")}
|
||||||
|
danger
|
||||||
|
onClick={handleDelete}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon="fa-solid fa-xmark"
|
||||||
|
label={t("common.cancel")}
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import PostCard from "./PostCard";
|
import PostCard from "./PostCardNew";
|
||||||
|
import { FeedSkeleton } from "../ui/Skeleton";
|
||||||
import { matchesContentFilter } from "../Filters/ContentFilters";
|
import { matchesContentFilter } from "../Filters/ContentFilters";
|
||||||
|
|
||||||
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
export default function PostList({ posts, loading, error, selectedPost, onSelectPost, onPostDeleted, onPostUpdated, contentFilter = "all" }) {
|
||||||
|
|
@ -31,6 +32,15 @@ export default function PostList({ posts, loading, error, selectedPost, onSelect
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [filtered.length]);
|
}, [filtered.length]);
|
||||||
|
|
||||||
|
// Show skeleton loading state
|
||||||
|
if (loading && posts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="post-list" ref={listRef}>
|
||||||
|
<FeedSkeleton count={3} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="post-list" ref={listRef}>
|
<div className="post-list" ref={listRef}>
|
||||||
{error && <p className="status-text status-error">{error}</p>}
|
{error && <p className="status-text status-error">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BlurImage - Progressive image loading with blur-up effect
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.src - Image source URL
|
||||||
|
* @param {string} [props.alt] - Alt text
|
||||||
|
* @param {string} [props.className] - Additional CSS classes
|
||||||
|
* @param {string} [props.placeholderColor] - Background color while loading
|
||||||
|
* @param {Function} [props.onLoad] - Callback when image loads
|
||||||
|
* @param {Function} [props.onError] - Callback when image fails
|
||||||
|
* @param {React.ReactNode} [props.fallback] - Fallback content on error
|
||||||
|
* @param {boolean} [props.zoom] - Enable zoom on hover
|
||||||
|
*/
|
||||||
|
export function BlurImage({
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
className = '',
|
||||||
|
placeholderColor = 'rgba(30, 40, 60, 0.8)',
|
||||||
|
onLoad,
|
||||||
|
onError,
|
||||||
|
fallback,
|
||||||
|
zoom = true,
|
||||||
|
}) {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [isInView, setIsInView] = useState(false);
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
// Intersection Observer for lazy loading
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsInView(true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '50px', threshold: 0.01 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset states when src changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoaded(false);
|
||||||
|
setHasError(false);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
setIsLoaded(true);
|
||||||
|
onLoad?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setHasError(true);
|
||||||
|
onError?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasError && fallback) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative overflow-hidden ${className}`}
|
||||||
|
style={{ backgroundColor: placeholderColor }}
|
||||||
|
>
|
||||||
|
{/* Loading shimmer */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLoaded && !hasError && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-surface-800/60 via-surface-700/40 to-surface-800/60"
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
|
||||||
|
initial={{ x: '-100%' }}
|
||||||
|
animate={{ x: '100%' }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Actual image */}
|
||||||
|
{isInView && (
|
||||||
|
<motion.img
|
||||||
|
ref={imgRef}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
className={`w-full h-full object-cover transition-transform duration-500 ease-out ${
|
||||||
|
zoom ? 'group-hover:scale-105' : ''
|
||||||
|
}`}
|
||||||
|
initial={{ opacity: 0, scale: 1.1, filter: 'blur(20px)' }}
|
||||||
|
animate={
|
||||||
|
isLoaded
|
||||||
|
? { opacity: 1, scale: 1, filter: 'blur(0px)' }
|
||||||
|
: { opacity: 0, scale: 1.1, filter: 'blur(20px)' }
|
||||||
|
}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{hasError && !fallback && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-surface-800/80"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-surface-400">
|
||||||
|
<i className="fa-solid fa-image text-3xl mb-2 opacity-50" />
|
||||||
|
<p className="text-xs">Image unavailable</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GalleryImage - BlurImage with gallery navigation overlay
|
||||||
|
*/
|
||||||
|
export function GalleryImage({
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
className = '',
|
||||||
|
currentIndex,
|
||||||
|
totalCount,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onError,
|
||||||
|
fallback,
|
||||||
|
}) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative ${className}`}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<BlurImage
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full"
|
||||||
|
onError={onError}
|
||||||
|
fallback={fallback}
|
||||||
|
zoom
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gallery counter */}
|
||||||
|
{totalCount > 1 && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-3 right-3 px-3 py-1.5 rounded-full
|
||||||
|
bg-black/60 backdrop-blur-sm text-white/90
|
||||||
|
text-xs font-semibold tracking-wide
|
||||||
|
border border-white/10"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{currentIndex + 1}/{totalCount}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation arrows */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{totalCount > 1 && isHovered && (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2
|
||||||
|
w-10 h-10 rounded-full
|
||||||
|
bg-black/50 backdrop-blur-sm
|
||||||
|
border border-white/15 text-white
|
||||||
|
flex items-center justify-center
|
||||||
|
hover:bg-accent/40 hover:border-accent/50
|
||||||
|
transition-colors duration-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPrev?.();
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-left" />
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2
|
||||||
|
w-10 h-10 rounded-full
|
||||||
|
bg-black/50 backdrop-blur-sm
|
||||||
|
border border-white/15 text-white
|
||||||
|
flex items-center justify-center
|
||||||
|
hover:bg-accent/40 hover:border-accent/50
|
||||||
|
transition-colors duration-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNext?.();
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 10 }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-chevron-right" />
|
||||||
|
</motion.button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/40 to-transparent pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlurImage;
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loading component with shimmer animation
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} [props.className] - Additional CSS classes
|
||||||
|
* @param {'text'|'avatar'|'image'|'card'|'button'} [props.variant] - Preset variants
|
||||||
|
* @param {number} [props.width] - Custom width
|
||||||
|
* @param {number} [props.height] - Custom height
|
||||||
|
* @param {boolean} [props.rounded] - Full rounded (for avatars)
|
||||||
|
* @param {number} [props.count] - Number of skeleton items to render
|
||||||
|
*/
|
||||||
|
export function Skeleton({
|
||||||
|
className = '',
|
||||||
|
variant = 'text',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rounded = false,
|
||||||
|
count = 1,
|
||||||
|
}) {
|
||||||
|
const variants = {
|
||||||
|
text: 'h-4 w-full',
|
||||||
|
avatar: 'w-12 h-12 rounded-full',
|
||||||
|
image: 'w-full h-48 rounded-xl',
|
||||||
|
card: 'w-full h-64 rounded-2xl',
|
||||||
|
button: 'h-10 w-24 rounded-full',
|
||||||
|
title: 'h-6 w-3/4',
|
||||||
|
snippet: 'h-4 w-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = variants[variant] || variants.text;
|
||||||
|
const roundedClass = rounded ? 'rounded-full' : '';
|
||||||
|
|
||||||
|
const style = {};
|
||||||
|
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
|
||||||
|
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
|
||||||
|
|
||||||
|
const items = Array.from({ length: count }, (_, i) => i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className={`
|
||||||
|
relative overflow-hidden
|
||||||
|
bg-gradient-to-r from-surface-800/60 via-surface-700/40 to-surface-800/60
|
||||||
|
${baseClasses}
|
||||||
|
${roundedClass}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
style={style}
|
||||||
|
initial={{ opacity: 0.6 }}
|
||||||
|
animate={{ opacity: [0.6, 0.8, 0.6] }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Shimmer overlay */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent"
|
||||||
|
initial={{ x: '-100%' }}
|
||||||
|
animate={{ x: '100%' }}
|
||||||
|
transition={{
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostCard Skeleton - Complete loading state for a post card
|
||||||
|
*/
|
||||||
|
export function PostCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="glass-card p-0 overflow-hidden"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
<Skeleton variant="avatar" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton variant="text" className="w-32 h-4" />
|
||||||
|
<Skeleton variant="text" className="w-20 h-3" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="button" className="w-16" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<Skeleton variant="image" className="rounded-none h-56" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<Skeleton variant="title" />
|
||||||
|
<Skeleton variant="snippet" />
|
||||||
|
<Skeleton variant="snippet" className="w-2/3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-white/5">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton variant="text" className="w-12 h-4" />
|
||||||
|
<Skeleton variant="text" className="w-12 h-4" />
|
||||||
|
<Skeleton variant="text" className="w-12 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton variant="button" />
|
||||||
|
<Skeleton variant="button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed Skeleton - Multiple post card skeletons
|
||||||
|
*/
|
||||||
|
export function FeedSkeleton({ count = 3 }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<PostCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skeleton;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
// UI Components - Modern Design System for Sociowire
|
||||||
|
export { Skeleton, PostCardSkeleton, FeedSkeleton } from './Skeleton';
|
||||||
|
export { BlurImage, GalleryImage } from './BlurImage';
|
||||||
|
|
@ -5,6 +5,9 @@ import ReactDOM from "react-dom/client";
|
||||||
import "./i18n"; // Initialize i18n before App
|
import "./i18n"; // Initialize i18n before App
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
|
|
||||||
|
// Tailwind CSS (new design system)
|
||||||
|
import "./styles/tailwind.css";
|
||||||
|
|
||||||
import "./styles/base.css";
|
import "./styles/base.css";
|
||||||
import "./styles/layout.css";
|
import "./styles/layout.css";
|
||||||
import "./styles/topbar.css";
|
import "./styles/topbar.css";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SOCIOWIRE - Custom Theme & Components (Tailwind v4)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Primary accent */
|
||||||
|
--color-accent: #60a5fa;
|
||||||
|
--color-accent-light: #93c5fd;
|
||||||
|
--color-accent-dark: #3b82f6;
|
||||||
|
--color-accent-glow: rgba(96, 165, 250, 0.3);
|
||||||
|
|
||||||
|
/* Surface colors */
|
||||||
|
--color-surface-50: #f8fafc;
|
||||||
|
--color-surface-100: #f1f5f9;
|
||||||
|
--color-surface-200: #e2e8f0;
|
||||||
|
--color-surface-300: #cbd5e1;
|
||||||
|
--color-surface-400: #94a3b8;
|
||||||
|
--color-surface-500: #64748b;
|
||||||
|
--color-surface-600: #475569;
|
||||||
|
--color-surface-700: #334155;
|
||||||
|
--color-surface-800: #1e293b;
|
||||||
|
--color-surface-900: #0f172a;
|
||||||
|
--color-surface-950: #050816;
|
||||||
|
|
||||||
|
/* Glass backgrounds */
|
||||||
|
--color-glass-light: rgba(248, 250, 252, 0.98);
|
||||||
|
--color-glass-dark: rgba(18, 30, 52, 0.97);
|
||||||
|
--color-glass-blue: rgba(7, 18, 37, 0.98);
|
||||||
|
--color-glass-card: rgba(40, 48, 70, 0.9);
|
||||||
|
--color-glass-card-hover: rgba(50, 58, 80, 0.95);
|
||||||
|
|
||||||
|
/* Category colors */
|
||||||
|
--color-category-news: #60a5fa;
|
||||||
|
--color-category-breaking: #ef4444;
|
||||||
|
--color-category-market: #fbbf24;
|
||||||
|
--color-category-friends: #22c55e;
|
||||||
|
--color-category-events: #a78bfa;
|
||||||
|
--color-category-finance: #14b8a6;
|
||||||
|
--color-category-live: #f87171;
|
||||||
|
--color-category-video: #f472b6;
|
||||||
|
--color-category-image: #34d399;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-glass: 0 4px 32px rgba(0, 0, 0, 0.4), 0 0 30px rgba(59, 130, 246, 0.05);
|
||||||
|
--shadow-glass-hover: 0 8px 48px rgba(0, 0, 0, 0.5), 0 0 40px rgba(96, 165, 250, 0.1);
|
||||||
|
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
--shadow-card-hover: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 32px rgba(96, 165, 250, 0.08);
|
||||||
|
--shadow-glow: 0 0 20px rgba(96, 165, 250, 0.4);
|
||||||
|
--shadow-glow-sm: 0 0 10px rgba(96, 165, 250, 0.3);
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
--animate-shimmer: shimmer 2s linear infinite;
|
||||||
|
--animate-fade-in: fadeIn 0.3s ease-out;
|
||||||
|
--animate-slide-up: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--animate-scale-in: scaleIn 0.2s ease-out;
|
||||||
|
--animate-live-pulse: livePulse 1.5s ease-in-out infinite;
|
||||||
|
|
||||||
|
/* Blur */
|
||||||
|
--blur-glass: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
0% { opacity: 0; transform: translateY(10px); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
0% { opacity: 0; transform: scale(0.95); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes livePulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Base Styles
|
||||||
|
───────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(96, 165, 250, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Component Classes
|
||||||
|
───────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Glass Card */
|
||||||
|
.glass-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-glass-card);
|
||||||
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
background: var(--color-glass-card-hover);
|
||||||
|
border-color: rgba(96, 165, 250, 0.25);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass Panel */
|
||||||
|
.glass-panel {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--color-glass-dark);
|
||||||
|
backdrop-filter: blur(var(--blur-glass));
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient Text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(to right, var(--color-accent), #a78bfa, #f472b6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Base */
|
||||||
|
.btn-glass {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(71, 85, 105, 0.2);
|
||||||
|
color: var(--color-surface-300);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-glass:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.15);
|
||||||
|
border-color: rgba(96, 165, 250, 0.3);
|
||||||
|
color: var(--color-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-glass:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-glass.active {
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(139, 92, 246, 0.2));
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
color: var(--color-accent);
|
||||||
|
box-shadow: var(--shadow-glow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar with gradient ring */
|
||||||
|
.avatar-ring {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-dark), #8b5cf6, #ec4899);
|
||||||
|
box-shadow: var(--shadow-glow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-ring img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-surface-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.2);
|
||||||
|
box-shadow: var(--shadow-glow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-news {
|
||||||
|
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(59, 130, 246, 0.2));
|
||||||
|
color: #60a5fa;
|
||||||
|
border-color: rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-breaking {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.2));
|
||||||
|
color: #f87171;
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-market {
|
||||||
|
background: linear-gradient(135deg, rgba(251, 191, 36, 0.2), rgba(245, 158, 11, 0.2));
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-friends {
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(22, 163, 74, 0.2));
|
||||||
|
color: #4ade80;
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-events {
|
||||||
|
background: linear-gradient(135deg, rgba(167, 139, 250, 0.2), rgba(139, 92, 246, 0.2));
|
||||||
|
color: #c4b5fd;
|
||||||
|
border-color: rgba(167, 139, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-live {
|
||||||
|
background: linear-gradient(135deg, rgba(248, 113, 113, 0.2), rgba(239, 68, 68, 0.2));
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: rgba(248, 113, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live indicator */
|
||||||
|
.live-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6);
|
||||||
|
animation: livePulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utilities */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue