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:
Your Name 2026-01-19 09:05:51 +00:00
parent 197151d818
commit 8f2e5b8c18
10 changed files with 2318 additions and 1 deletions

733
package-lock.json generated
View File

@ -10,6 +10,8 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.27.0",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@ -23,16 +25,33 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"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": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -1362,6 +1381,277 @@
"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": {
"version": "7.20.5",
"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",
"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": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
@ -1668,6 +1995,15 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1780,6 +2116,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1804,6 +2150,20 @@
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -2185,6 +2545,47 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2307,6 +2708,13 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2482,6 +2890,16 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"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": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
@ -2587,6 +3005,267 @@
"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": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.16.1.tgz",
@ -2649,6 +3328,16 @@
"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": {
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.15.0.tgz",
@ -2733,6 +3422,21 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2906,6 +3610,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -2915,6 +3620,13 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
@ -3212,6 +3924,27 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",

View File

@ -12,6 +12,8 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.27.0",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@ -25,13 +27,17 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^5.0.0"
},
"overrides": {

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@ -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>
);
}

View File

@ -1,5 +1,6 @@
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";
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();
}, [filtered.length]);
// Show skeleton loading state
if (loading && posts.length === 0) {
return (
<div className="post-list" ref={listRef}>
<FeedSkeleton count={3} />
</div>
);
}
return (
<div className="post-list" ref={listRef}>
{error && <p className="status-text status-error">{error}</p>}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
// UI Components - Modern Design System for Sociowire
export { Skeleton, PostCardSkeleton, FeedSkeleton } from './Skeleton';
export { BlurImage, GalleryImage } from './BlurImage';

View File

@ -5,6 +5,9 @@ import ReactDOM from "react-dom/client";
import "./i18n"; // Initialize i18n before App
import App from "./App.jsx";
// Tailwind CSS (new design system)
import "./styles/tailwind.css";
import "./styles/base.css";
import "./styles/layout.css";
import "./styles/topbar.css";

284
src/styles/tailwind.css Normal file
View File

@ -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;
}